systemd path units: start a service when a file appears
A systemd path unit starts its paired service when a file or directory condition is met — not when a calendar fires. Monitoring sits on inotify(7) as documented in man systemd.path, and by default it activates a .service with the same basename. This post covers when to choose path vs timer, a minimal .path+.service pair, and journal checks. No OnCalendar retread, no affiliate, no invented ops stories.
When do you split timer vs path?
One-line answer: Use a .timer (e.g. OnCalendar=) for time/schedule; use a .path for existence, change, or non-empty directory. Both are activation units that start a paired service; the trigger axis differs.
| Axis | .timer | .path |
|---|---|---|
| Trigger | Wall-clock / monotonic (systemd.timer) | PathExists= / PathChanged= / … (systemd.path) |
| Typical question | “Every day at 03:00?” | “When this file appears / when a writer closes it?” |
| Internals | timerfd / calendar | inotify — remote NFS-style changes that inotify cannot see are a documented limitation |
| Default install target | timers.target | paths.target |
Contrast only: we do not repeat OnCalendar expressions or Persistent= from the timer post. If the boundary is a clock, use timer; if it is “what is on this path?”, use path. A drop directory that should run when a file lands is path territory; a job that must run on schedule even with no file is timer territory.
What is a minimal .path + .service example?
One-line answer: Put one or more absolute-path conditions in foo.path’s [Path], pair with a usually Type=oneshot foo.service, and enable the .path. Without Unit=, the default target is the same name with a .service suffix (man systemd.path).
Example: run a oneshot when /var/spool/drop/ready exists.
# /etc/systemd/system/drop-ingest.service
[Unit]
Description=Ingest when drop ready file exists
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ingest-drop.sh
# /etc/systemd/system/drop-ingest.path
[Unit]
Description=Watch for /var/spool/drop/ready
[Path]
PathExists=/var/spool/drop/ready
[Install]
WantedBy=paths.target
Apply:
sudo systemctl daemon-reload
sudo systemctl enable --now drop-ingest.path
systemctl status drop-ingest.path
Condition cheat sheet (from the man page):
| Directive | Meaning |
|---|---|
PathExists= | Activate if the file/dir exists; if it already exists when the path unit activates, fire immediately |
PathExistsGlob= | Activate if at least one glob match exists |
PathChanged= | Activate on change; not on every write, but when a file opened for writing is closed |
PathModified= | Like PathChanged=, but also on simple writes |
DirectoryNotEmpty= | Activate if the directory has ≥1 entry (immediate if already non-empty at activation) |
Notes:
- Arguments must be absolute paths.
MakeDirectory=truecan create watch directories first; it is ignored forPathExists=(DirectoryMode=defaults to 0755).- Dotfiles (names starting with
.) are generally ignored. - When the service exits, paths are checked again immediately, so use service
StartLimitIntervalSec=/StartLimitBurst=and pathTriggerLimitIntervalSec=/TriggerLimitBurst=(defaults 2s / 200 since v250+) as documented against busy loops. - For “file appeared”,
PathExists=is the minimal choice; for “content finished changing”, preferPathChanged=(orPathModified=if you need write-by-write).
If it does not start, what do you look at in the journal?
One-line answer: Inspect the path unit and the service unit together, and separate “not watching / trigger OK but Exec failed / rate limit failed the path unit”.
systemctl status drop-ingest.path drop-ingest.service
journalctl -u drop-ingest.path -u drop-ingest.service -b --no-pager
systemctl list-units --type=path --all
systemctl show drop-ingest.path -p ActiveState -p SubState -p TriggerLimitIntervalUSec -p TriggerLimitBurst
Order:
- Is the path unit active (waiting)? Confirm you enabled
.path, not only.service, andWantedBy=paths.target. - Path and permissions: Absolute path? Intermediate directories inaccessible? The man page says systemd can watch for permission changes and notice when access becomes possible — a wrong path still never matches.
- Condition type: With
PathExists=, deleting the file clears the condition.PathChanged=/PathModified=do not retroactively fire for “already existed at activation” the way existence conditions do. - Service journal: If the path fired but
ExecStart=failed (permissions, missing interpreter), the failure is on the service. - Loops and limits: If the condition stays true after the service exits, restarts can follow. Hitting the service start rate limit is propagated to the path unit and can stop watching (man systemd.path). Hitting
TriggerLimit*puts the path unit into failure until restarted. - inotify limits: Changes made by another machine on NFS may not appear as expected. Validate on local storage first.
Triggered units may receive environment variables from the service manager; see systemd.exec(5) (“Environment Variables Set or Propagated by the Service Manager”).
Frequently asked questions
Q. Should I enable the service too?
Usually enable only the path. Like timers, the activation unit starts the paired service. You can still systemctl start drop-ingest.service to test the job alone.
Q. Can I combine PathExists and PathChanged?
Yes. The man page allows multiple directives of the same or different types. Assigning the empty string resets that option’s path list.
Q. On embedded, can path replace cron entirely?
No. Periodic batches still want a timer. Use path when the boundary is a path event (drop dir, flag file, state next to a socket).
What should you remember?
- Calendar → timer; path condition → path. Do not retell OnCalendar here — only split the axis.
- Minimal pair:
PathExists=(or Changed/Modified) + matchingType=oneshotservice;enable --now *.path. - If silent:
journalctl -u …path -u …service, then absolute path, condition semantics, StartLimit/TriggerLimit, and inotify limits.
Sources: systemd.path(5), systemd.service(5), systemd.timer(5) (contrast only), inotify(7).