Stop Hand-Crafting /run and /tmp Trees: Practical systemd-tmpfiles on Linux
Most Linux hosts already run systemd-tmpfiles on every boot. Packages drop snippets under /usr/lib/tmpfiles.d/, the early boot units create volatile trees under /run and friends, and a timer later ages junk out of /tmp and /var/tmp.
Operators still keep reinventing the same work with ad-hoc shell:
mkdir -p /var/lib/myapp/cache
chown myapp:myapp /var/lib/myapp/cache
chmod 0750 /var/lib/myapp/cache
find /var/lib/myapp/cache -mtime +7 -delete
That works until the next image rebuild, the next package update that races your script, or the next reboot where /run is empty again and nothing recreates the sockets directory.
tmpfiles.d is the declarative replacement: one line per path, applied the same way at boot, on package install hooks, and on a periodic clean timer.
What actually runs
Three system units do the work (names from systemd-tmpfiles(8)):
| Unit | Role |
|---|---|
systemd-tmpfiles-setup-dev.service |
Early device-node /dev setup |
systemd-tmpfiles-setup.service |
Create/adjust paths during boot (--create, with --boot lines) |
systemd-tmpfiles-clean.service + .timer
|
Age-based cleanup (--clean) |
Typical boot invocation (documented in the same man page):
systemd-tmpfiles --remove --create
Useful direct calls when you change config:
# Apply create/adjust rules now (safe runtime subset)
sudo systemd-tmpfiles --create
# Run age cleanup now
sudo systemd-tmpfiles --clean
# See which snippets would be merged
systemd-tmpfiles --cat-config | less
# Apply only one admin file
sudo systemd-tmpfiles --create /etc/tmpfiles.d/myapp.conf
After editing drop-ins, restarting systemd-tmpfiles-clean.service re-applies settings that are safe at runtime. Lines marked with ! only run when --boot is passed — do not force those on a live multi-user system unless you understand the impact.
Config locations and precedence
Search paths (system instance):
-
/etc/tmpfiles.d/*.conf— admin overrides -
/run/tmpfiles.d/*.conf— runtime -
/usr/lib/tmpfiles.d/*.conf— vendor/package (plus/usr/local/lib/tmpfiles.don newer releases)
Same basename: /etc wins over /run wins over /usr/lib. Files are then sorted lexicographically by filename. If two different files claim the same path, the lexicographically earliest filename wins; later conflicts are logged as errors.
Disable a vendor file without deleting package content:
sudo ln -s /dev/null /etc/tmpfiles.d/some-vendor.conf
User instance (separate from system cleanup) reads ~/.config/user-tmpfiles.d/, $XDG_RUNTIME_DIR/user-tmpfiles.d/, and share paths under ~/.local/share/user-tmpfiles.d/ and /usr/share/user-tmpfiles.d/. Important nuance from the man page: system age rules for /tmp still apply to user files placed there, even if the user instance turns cleanup off.
Line format
#Type Path Mode User Group Age Argument
d /run/myapp 0755 myapp myapp - -
f /run/myapp/ready 0644 myapp myapp - -
L /run/myapp/log - - - - /var/log/myapp
Fields:
-
Type — operation letter, optional modifiers (
+,!,-,=,~,^) -
Path — absolute path (specifiers like
%h,%Callowed) -
Mode — e.g.
0755;-means default (0755dirs,0644files) or “don’t change” forz/Z - User / Group — name or numeric ID; use system accounts resolvable in early boot
-
Age — cleanup threshold (
10d,1h,0, or-) -
Argument — content, symlink target, device
major:minor, ACL text, etc.
Types you will actually use
Create and own paths
# Directory; contents eligible for age cleanup if Age is set
d /run/myapp 0750 myapp myapp - -
# Like d, but --remove empties it (boot wipe of leftovers)
D /run/myapp/tmp 0750 myapp myapp 1d -
# Create file only if missing; optional content in Argument
f /var/lib/myapp/state.env 0640 myapp myapp - -
# Always truncate/write content
f+ /run/myapp/banner 0644 root root - Hello from tmpfiles
# Write into an existing file (globs OK); w+ appends
w /sys/module/dummy/parameters/foo - - - - 1
Leading parents for create types are auto-created as root:root mode 0755. If you need different ownership on parents, add explicit d lines first.
Clean without owning create
# Adjust mode/owner of existing dirs + age their contents (globs OK)
e /var/tmp/myapp 0750 myapp myapp 7d -
e does nothing useful unless at least one of mode, user, group, or age is set.
Symlinks, pipes, copy-from-factory
L /etc/os-release - - - - ../usr/lib/os-release
L+ /run/service/current - - - - /opt/service/releases/1.2.3
p /run/myapp/cmd.fifo 0600 myapp myapp - -
# Copy from source if target missing/empty
C /var/lib/myapp/defaults - - - - /usr/share/myapp/factory
L/C with omitted argument pull same-named objects from /usr/share/factory/.
Cleanup exclusions and forced removal
# Keep this tree during age clean of a parent
x /var/tmp/myapp/keep - - - - -
# Remove empty path / recursive tree (usually with ! for boot-only)
r /run/myapp/stale-lock - - - - -
R! /run/myapp/scratch - - - - -
x excludes a path (and below) from age cleaning; it does not block r/R.
Fix permissions after the fact
z /var/lib/myapp/data 0750 myapp myapp - -
Z /var/lib/myapp 0750 myapp myapp - -
z/Z adjust mode, owner, and restore SELinux context when policy is active. They accept globs.
ACLs and attributes (when you need them)
a /var/lib/shared 0750 root shared - u:alice:rwx,u:bob:r-x
h /var/log/immutable.log - - - - +i
Type modifiers that prevent outages
| Modifier | Meaning |
|---|---|
! |
Only with --boot (unsafe on a live system — e.g. wiping X11 locks) |
- |
Create failure is non-fatal (great for optional sysfs writes in containers) |
+ |
Recreate/truncate/replace semantics for f/w/L/p/c/b (and force-copy for C+ on newer systemd) |
= |
If an existing object has the wrong type, remove it first |
~ on type |
Base64-decode Argument before write (f/w family) |
^ on type |
Read Argument from a service credential name (tmpfiles.extra / credentials docs) |
Example from the manual — boot-only lock cleanup paired with a normal directory line:
d /tmp/.X11-unix 1777 root root 10d
r! /tmp/.X[0-9]*-lock
Container-safe sysctl-style write:
w- /proc/sys/vm/swappiness - - - - 10
Age cleanup without foot-guns
Age applies to d, D, e, v, q, Q, C, x, and X.
Formats: 10d, 12h, 30m, 1w, or combinations that sum. 0 means delete matching contents every --clean run.
Default “is this still young?” check uses mtime, atime, and (for non-directories) ctime. Directory ctime is ignored by default so the cleaner does not keep resurrecting directories it just touched.
Narrow the clocks with age-by:
# Files by mtime/birth; directories by atime; threshold 1 hour
d /tmp/foo/bar - - - bmA:1h -
Prefix Age with ~ to clean only one level down, not the directory’s immediate children naming semantics described in tmpfiles.d(5) — useful when the top-level entries must stay.
Applications can take a shared BSD flock on a directory to pause aging for that subtree while a job runs.
Lab: app runtime dir + aged cache
Assume a system user myapp already exists (create with useradd --system / systemd-sysusers as you prefer).
sudo tee /etc/tmpfiles.d/myapp.conf >/dev/null <<'EOF'
# Runtime (tmpfs /run) — recreated every boot
d /run/myapp 0750 myapp myapp - -
d /run/myapp/sockets 0750 myapp myapp - -
f /run/myapp/README 0640 myapp myapp - managed-by-tmpfiles
# On-disk cache with 7-day age cleanup of contents
d /var/cache/myapp 0750 myapp myapp 7d -
# Keep one subdirectory forever while parent ages
x /var/cache/myapp/pinned - - - - -
# Permission repair if something else chowns the tree
Z /var/cache/myapp 0750 myapp myapp - -
EOF
sudo systemd-tmpfiles --create /etc/tmpfiles.d/myapp.conf
Verify:
namei -l /run/myapp/sockets
ls -la /run/myapp /var/cache/myapp
Simulate cache files and clean:
sudo -u myapp touch /var/cache/myapp/old.bin /var/cache/myapp/pinned/keep.bin
# After aging window, or with Age 0 in a test copy of the snippet:
sudo systemd-tmpfiles --clean /etc/tmpfiles.d/myapp.conf
For a force-clean lab without waiting, use a separate test snippet with e /tmp/myapp-cache-test 0755 myapp myapp 0 -, create files, then --clean.
Prefer unit directories when the lifetime matches the service
tmpfiles.d is for paths whose lifetime is independent of one unit, or that need richer rules (globs, ACLs, factory copy, boot-only removes).
For a single service, prefer systemd.exec(5) directives so the directory dies with the stop:
# /etc/systemd/system/myapp.service
[Service]
User=myapp
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750
StateDirectory=myapp
CacheDirectory=myapp
LogsDirectory=myapp
ExecStart=/usr/local/bin/myapp
That yields /run/myapp, /var/lib/myapp, /var/cache/myapp, /var/log/myapp with matching ownership, without a parallel tmpfiles snippet.
Rule of thumb:
-
Service-private runtime/state/cache/log →
RuntimeDirectory=/StateDirectory=/ … -
Shared, multi-service, boot-seeded, aged, or factory-copied paths →
tmpfiles.d -
Secrets →
LoadCredential=/systemd-creds, not world-readable tmpfiles content
Image builds and credentials
Offline OS trees:
sudo systemd-tmpfiles --root=/mnt/rootfs --create -E
# -E skips /dev /proc /run /sys so you do not populate overmount points
Disk images: --image=PATH (implies -E).
First-boot extras can arrive via the tmpfiles.extra credential: extra tmpfiles lines processed after on-disk drop-ins (cannot override earlier same-path winners). Combine with ^ type modifier to pull file bodies from named credentials.
Operational checklist
- Put admin rules in
/etc/tmpfiles.d/name.conf— never edit vendor files in/usr/lib. - Run
systemd-tmpfiles --createafter edits; use--bootonly in early-boot context. - Confirm the clean timer:
systemctl status systemd-tmpfiles-clean.timer. - Use
x/Xbefore aggressive parentAge=values. - Mark destructive lines with
!. - Keep User=/Group= resolvable without LDAP at early boot.
- Mask bad vendor snippets with
/etc/tmpfiles.d/foo.conf -> /dev/null. - Do not store secrets in
f/warguments; use credentials.
Boundaries (what this article is not)
- Not
systemd-sysusers(account creation) orsystemd-firstboot(machine identity seeding) - Not
RuntimeDirectory=deep-dive beyond the boundary above - Not
systemd-credssecret injection (covered separately) - Not Btrfs quota design beyond noting
v/q/Qexist for subvolumes - Not a replacement for backups — age cleanup deletes data on purpose
References
- tmpfiles.d(5) — Debian manpages
- systemd-tmpfiles(8) — Debian manpages
- systemd.exec(5) — RuntimeDirectory= and friends
- Upstream overview: systemd project documentation for tmpfiles.d / systemd-tmpfiles
Ship the snippet next to the unit, not a shell mkdir in ExecStartPre=. Your boot path stays boring — which is exactly what you want from /run and /tmp.
Top comments (0)