systemd.path Units: File Conditions, Service Retriggers, and Reconciliation
Use systemd.path units for local file conditions, understand inotify limits and retriggers, and make the activated service reconcile durable state.
A systemd.path unit activates another unit when a configured filesystem condition is met. It can start a service when a sentinel file exists, when a directory becomes nonempty, or when a watched file changes. The useful design model is condition-to-service activation, not “one durable job per filesystem event.” A path unit does not replace a transactional queue, a database, or a service that reconciles current state.
This distinction determines whether a path unit is appropriate. A local directory where a service can scan and drain pending work is a good fit. A design that must preserve every intermediate write, receive changes made by another machine on remote NFS, or prove that a particular payload was durably processed needs a separate durable mechanism. systemd documents that path monitoring uses inotify and shares its limitations, including not detecting changes made by other machines on remote NFS filesystems.
Choose the path condition for the state you need
PathExists= watches whether a path exists; if it already exists when the path unit starts, the configured service is started immediately. PathExistsGlob= checks for a match. DirectoryNotEmpty= expresses a persistent backlog condition and also activates immediately if the directory is already nonempty. These predicates are usually more robust than trying to make an application depend on receiving every write notification.
PathChanged= activates when a watched path changes, including after a file opened for writing is closed. It does not activate for every write. PathModified= is similar but also activates on simple writes. The distinction can reduce or increase activation frequency, but neither turns a path unit into an event log with a durable event record. Arguments must be absolute paths, multiple directives may be combined, and systemd generally ignores dot-prefixed filenames when monitoring paths.
Pair a path unit with an idempotent service
This example watches a private inbox directory and starts a service that drains all pending reports. The matching base name means report-import.path activates report-import.service by default; Unit= can be used when a different service name is intentional.
# /etc/systemd/system/report-import.path
[Unit]
Description=Watch the report import inbox
[Path]
DirectoryNotEmpty=/var/lib/report-import/incoming
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/report-import.service
[Unit]
Description=Reconcile pending report imports
[Service]
Type=oneshot
User=report-import
Group=report-import
ExecStart=/usr/local/libexec/report-import --drain /var/lib/report-import/incoming
The service should inspect current contents, claim each item using an application-level locking or atomic-move strategy, validate the file, commit its result durably, and then remove or archive the input only after success. It should tolerate being started again after a crash. If an item is malformed, quarantine it with a visible reason rather than leaving the same failing item at the head of an unchanged directory forever. If the service exits while the watched directory remains nonempty, systemd rechecks the condition and can start it again immediately, so a permanently failing operation can become a start loop. The service’s StartLimitIntervalSec= and StartLimitBurst= settings provide protection against runaway activation, not a substitute for retry and quarantine policy; the path unit also has separate TriggerLimitIntervalSec= and TriggerLimitBurst= limits.
Enable the watcher, not the oneshot service:
sudo systemctl daemon-reload
sudo systemctl enable --now report-import.path
systemctl status report-import.path report-import.service
journalctl -u report-import.path -u report-import.service --since today
The use of sudo here is an administrative operation on the example host. Review the unit and filesystem ownership before enabling it. Run systemd-analyze verify on both unit files before deployment, then test with an isolated inbox. Confirm that a file already present at startup is handled, that a second file arriving during a run is eventually discovered, and that a failing item does not cause an uncontrolled loop.
Understand retriggers and at-start behavior
Path units are level-sensitive for several conditions: the service is activated while the configured state is true and can be activated again after the service terminates when the path condition still holds. This is useful for a drain-until-empty worker, but it can create a busy loop if the service does not change the observed state. Keep processing idempotent, bound work per run if needed, and record durable progress outside the trigger itself.
Not all predicates perform the same initial check. Existing PathExists= or glob matches and a nonempty DirectoryNotEmpty= condition cause activation when the path unit starts. PathChanged= and PathModified= do not activate merely because the predicate is already true at startup. If a service depends on processing preexisting data, choose a condition that represents that backlog or add an explicit initial reconciliation instead of waiting for an event that already occurred.
For files written via a temporary file followed by rename, watch the containing directory or use a backlog predicate that observes the committed filename. If the producer writes directly to the watched file, PathChanged= normally waits for the writer to close it; PathModified= can run while writes are still occurring. The service should still validate a complete format and claim data atomically. A notification is a hint to inspect state, not proof that the writer’s application-level transaction committed.
Diagnose the filesystem and unit boundary
When no service starts, inspect the active path unit and its effective configuration with systemctl status and systemctl cat. Check that the path is absolute, the service unit resolves to the expected name, parent directories are accessible, and the actual write happens on a filesystem whose events systemd can observe. Dot-prefixed temporary files may be ignored. For remote NFS changes performed by another machine, use a poller or a producer-side message/queue rather than expecting inotify to provide those events.
When the service starts repeatedly, inspect whether the configured condition remains true after the run. A PathExists= sentinel that is never removed, a nonempty inbox whose consumer keeps failing, or a service that leaves its work item in place can retrigger. Check start-limit messages and journal output, then fix the service’s state transition. Raising limits can hide the symptom while leaving the condition unchanged.
When the service never sees a file, distinguish watcher failure from application failure: use a harmless test file with the exact watched name and directory, inspect path and directory permissions, then look at the service’s own validation log. The producer may be writing to a different mount namespace or path than the system service watches. Do not broaden permissions to world-writable as a shortcut; make the producer and service share a deliberate group or staging directory.
Use a path unit when it can cheaply wake a service to reconcile observable local state. Use a durable queue, journal, database, or explicit producer-consumer protocol when the identity and ordering of every event must survive service downtime. The trigger is the wake-up mechanism; the service owns validation, retry, deduplication, persistence, and completion evidence.
Related:
- systemd ExecStart: Treat Unit Commands as Argument Vectors
- How to Replace a Cron Job with a systemd Timer
Sources: