Service Directories with systemd - Put Runtime, State, and Cache Data in the Right Place
A small service needs somewhere to put a Unix socket, a database, downloaded metadata, and perhaps its own log files. Creating one writable directory and placing everything inside it appears convenient, but it hides several different promises. Which files may disappear when the service stops? Which must survive a reboot? Which can be rebuilt? Who creates the directory before an unprivileged process starts?
On a Debian system using systemd, these questions can often be expressed in the unit itself. Directives such as RuntimeDirectory= and StateDirectory= let the service manager create standard directories with deliberate ownership and permissions. tmpfiles.d remains useful, but for a different boundary: filesystem objects whose lifetime is independent of one service, or rules that need age-based cleanup and more complex operations.
This is not a claim that every mkdir should disappear. It is a way to choose who owns a directory's lifecycle before choosing the mechanism.
Classify the data before creating the path
The directory name should follow what the data means, not merely which program writes it. For system services, systemd.exec(5) maps five unit settings to familiar parts of the filesystem:
| Purpose | Unit setting | System path | Environment variable |
|---|---|---|---|
| Volatile runtime data | RuntimeDirectory= | /run/ | $RUNTIME_DIRECTORY |
| Persistent application state | StateDirectory= | /var/lib/ | $STATE_DIRECTORY |
| Reproducible cache data | CacheDirectory= | /var/cache/ | $CACHE_DIRECTORY |
| Service-owned logs | LogsDirectory= | /var/log/ | $LOGS_DIRECTORY |
| Administrator-managed configuration | ConfigurationDirectory= | /etc/ | $CONFIGURATION_DIRECTORY |
The distinctions affect recovery. A socket under /run is meaningful only while a process is available to answer it. A queue database under /var/lib may be the record of unfinished work. Cached remote metadata under /var/cache should be reconstructible from another source. Treating all three as “application files” makes deletion and backup decisions needlessly dangerous.
Logs deserve a separate decision. A service that writes only to standard output and standard error may already be handled by the journal and may not need LogsDirectory=. Likewise, ConfigurationDirectory= does not mean a daemon should rewrite its own configuration. Configuration under /etc normally belongs to the administrator; the directive primarily makes the expected path explicit.
Why /run needs a lifecycle owner
Debian Policy section 9.1.4 says that /run is normally cleared at boot, commonly because it is a temporary filesystem. Software therefore cannot assume that a directory created there yesterday still exists after a restart. The older /var/run path is retained as a compatibility redirection, not as a separate persistent home.
A root-run setup script can recreate a directory, but then its ordering, ownership, error handling, and removal policy live somewhere other than the service. An ExecStartPre=/usr/bin/mkdir ... line is also awkward when the service runs without privilege: either the command cannot create the path, or it receives elevated permissions that the main process does not need.
RuntimeDirectory=example-worker gives PID 1 a narrower instruction. Before starting the process, systemd creates /run/example-worker. By default, the innermost directory is owned by the account configured with User= and Group=. When the service stops, that runtime directory is removed by default. The ownership, startup ordering, and ordinary cleanup rule are attached to the same unit.
A small unit with explicit writable areas
The following fictional worker uses runtime, state, and cache storage. /usr/bin/sleep is intentionally a harmless placeholder so the unit syntax can be checked without installing an application:
[Unit]
Description=Example background worker
[Service]
Type=simple
User=example-worker
Group=example-worker
ExecStart=/usr/bin/sleep infinity
RuntimeDirectory=example-worker
RuntimeDirectoryMode=0750
StateDirectory=example-worker
StateDirectoryMode=0750
CacheDirectory=example-worker
CacheDirectoryMode=0750
The account named in this example is illustrative; create a dedicated system account through the operating system or package-management process, and replace the placeholder command with the actual executable. The example asks systemd to prepare these paths:
/run/example-workerfor a socket or other volatile runtime objects;/var/lib/example-workerfor persistent state that is required for correctness;/var/cache/example-workerfor data that the worker can rebuild.
The matching environment variables contain the absolute paths. An application can read them if it is designed to do so, avoiding duplicated path assumptions. Existing software that expects a fixed conventional path can keep using it; the environment variable is useful but not mandatory.
The mode directives matter because the documented default is 0755. A mode of 0750 is reasonable when only the service account and its group should traverse the directory, but it is not a universal security value. A web server, backup agent, or monitoring process may need group access. Choose the mode from the actual readers and writers rather than copying the narrowest-looking number.
Stop does not mean delete everything
The most important difference between these settings is their removal behavior. The innermost directory created by RuntimeDirectory= is removed when the unit stops unless RuntimeDirectoryPreserve= changes that policy. Even when preservation is enabled, system /run is normally volatile across reboot.
Directories created through StateDirectory=, CacheDirectory=, LogsDirectory=, and ConfigurationDirectory= are not removed merely because the service stops. That is necessary for state, but it also means “disable the service” is not a data-erasure operation.
Systemd exposes an explicit cleaning interface for these managed resource classes. For example, a stopped unit can have its cache removed with:
sudo systemctl clean --what=cache example-worker.service
The systemctl(1) documentation requires the unit to be stopped for this operation. Selecting state, logs, configuration, or all has much larger consequences. A command named clean is not automatically safe; inspect the unit's directory declarations and backups first.
Where tmpfiles.d still belongs
The name tmpfiles is narrower than the tool. According to tmpfiles.d(5), its rules can create files, directories, pipes, and links; adjust ownership and modes; and perform time-based removal. It is commonly used for volatile and temporary paths, but it is a general declarative filesystem mechanism.
The same manual gives a useful dividing line: prefer RuntimeDirectory= and its related unit settings for directories owned by one service, because their lifetime is tied directly to that unit. Use tmpfiles.d when the object has an independent lifetime or needs a more complicated rule.
Age-based cache cleanup is one example. Suppose the unit manages /var/cache/example-worker, while imported files below one child directory should become eligible for cleanup after 14 days:
# /etc/tmpfiles.d/example-worker.conf
d /var/cache/example-worker/imports 0750 example-worker example-worker 14d -
The account must exist when the rule is applied. A d line creates the directory when needed, adjusts the specified mode and ownership, and subjects its contents to age-based cleanup when an age is present. “Older than 14 days” is not based on one simplistic timestamp: the manual documents how access, modification, status-change, and directory timestamps participate. If access patterns or backup scans matter, read those semantics before choosing an age.
The operation also matters. systemd-tmpfiles(8) distinguishes --create, --clean, and --remove. Creating declared paths does not imply that age cleanup ran, and cleanup is not the same as applying explicit removal rules. Test one configuration file deliberately rather than invoking broad removal commands against every installed rule.
Migration hazards that deserve a pause
Existing ownership may change recursively
For the managed writable directory classes other than configuration, systemd checks ownership against User= and Group=. The documentation warns that when an existing innermost directory has mismatched ownership, systemd can recursively change ownership below it. That is useful for a directory dedicated to one service and dangerous for a shared tree. Inventory the current contents before converting an old path.
DynamicUser= changes the protection model
With DynamicUser=, systemd protects persistent state, cache, and log directories against recycled user IDs by using protected host-side locations and presenting the expected standard paths to the service. This interaction is a reason to use the managed directory settings, but it is not a reason to add DynamicUser=yes blindly. Database access, Unix sockets, supplementary groups, and files outside managed paths may all need redesign.
Directory creation is not a complete policy
These directives do not decide what belongs in a backup, rotate application log files, set storage quotas, encrypt data, or make every other filesystem path inaccessible. They can work with sandboxing options such as ProtectSystem=, but creating three correctly owned directories does not prove that the process can write only there.
Version differences also matter. The core directory settings have existed for years, while newer optional flags and cleanup features have later version requirements. Check the local manual and systemctl --version before using a directive copied from current online documentation.
Verify the declaration and the runtime result
Before installing a unit, systemd-analyze verify can report unknown directives, missing required dependencies, and executable problems:
systemd-analyze verify ./example-worker.service
A clean verification result confirms only the classes of checks the tool performs. It does not prove that the worker understands the paths, that its state is recoverable, or that the selected permissions fit every cooperating process.
After installation and a controlled start, inspect both systemd's normalized properties and the filesystem:
systemctl show example-worker.service \
--property=RuntimeDirectory,StateDirectory,CacheDirectory
stat -c '%A %U:%G %n' \
/run/example-worker \
/var/lib/example-worker \
/var/cache/example-worker
Then test the lifecycle instead of assuming it: stop the service and confirm that runtime artifacts disappear while required state remains; start it again and confirm the runtime path returns. Do not run that experiment against production state until deletion and recovery expectations are documented.
A modest rule for choosing the mechanism
If one service needs a conventional directory and its lifecycle follows that unit, start with the matching systemd directory directive. It keeps path, ownership, mode, startup ordering, and ordinary removal behavior close to the process that depends on them. If a filesystem object belongs to several services, must exist independently, needs age-based cleanup, or requires a type beyond those directory classes, evaluate a focused tmpfiles.d rule.
The goal is not fewer lines at any cost. It is a more truthful declaration: sockets are temporary, state survives, caches are replaceable, configuration belongs to administration, and cleanup happens only under a policy that names what may be lost.
References
- systemd project / Debian Manpages -
systemd.exec(5), systemd 257.13 - systemd project / Debian Manpages -
tmpfiles.d(5), systemd 257.13 - systemd project / Debian Manpages -
systemd-tmpfiles(8), systemd 257.13 - Debian Project - Debian Policy Manual 4.7.4.1, section 9.1.4:
/runand/run/lock - systemd project / Debian Manpages -
systemd-analyze(1), systemd 257.13 - systemd project / Debian Manpages -
systemctl(1), systemd 257.13
