How systemd.path Works
By the end of this lesson you will understand how systemd.path uses the Linux kernel's inotify subsystem to react to filesystem events, how the two-unit model connects a .path watcher to a .service executor, and why the trigger lifecycle matters for avoiding infinite loops.
The Two-Unit Model
Every path-based automation in systemd requires two unit files working together:
- Path unit (
name.path) — defines what to watch and which event to react to. - Service unit (
name.service) — defines what to run when the event fires.
A .path unit never executes a command directly. It only activates a .service unit. The service unit runs the actual script or command.
Automatic Name Matching
If the Unit= directive is omitted in the .path file, systemd automatically looks for a .service with the same base name:
process-drop.path ──► process-drop.service ──► /usr/local/bin/process-drop.sh
csv-import.path ──► csv-import.service ──► /usr/local/bin/import-csv.sh
deploy-trigger.path ──► deploy-trigger.service ──► /usr/local/bin/deploy.sh
You can override this with Unit=another-service.service in the [Path] section, but the default name-matching convention is what most setups use.
State Separation
The path unit and service unit have separate states. Understanding this is critical for debugging:
| Unit | What It Tells You |
|---|---|
process-drop.path | Watch status (active (waiting) or inactive), directive in use, last trigger time, enabled state |
process-drop.service | Exit code, stdout/stderr output, runtime duration, failure reason |
# Always check BOTH units when debugging
systemctl status process-drop.path --no-pager
systemctl status process-drop.service --no-pager
● process-drop.path - Watch /var/www/drop for incoming files
Loaded: loaded (/etc/systemd/system/process-drop.path; enabled)
Active: active (waiting) since Mon 2026-03-02 03:06:00 UTC; 5s ago
Triggers: ● process-drop.service
Watch: /var/www/drop (DirectoryNotEmpty)
● process-drop.service - Process one file from the drop folder
Loaded: loaded (/etc/systemd/system/process-drop.service; static)
Active: inactive (dead) since Mon 2026-03-02 03:06:05 UTC; 2s ago
Process: 1201 ExecStart=/usr/local/bin/process-drop.sh (code=exited, status=0/SUCCESS)
Main PID: 1201 (code=exited, status=0/SUCCESS)
How inotify Powers systemd.path
Under the hood, systemd.path uses the Linux kernel's inotify subsystem — the same mechanism used by tools like inotifywait, fswatch, and IDE file watchers.
What This Means in Practice
- No polling — the kernel pushes events to systemd when files change. Zero CPU usage while waiting.
- Instant reaction — there is no "check every X seconds" delay. Events fire within milliseconds.
- Watch limits — each inotify watch consumes a kernel descriptor. The default limit is 8192 watches system-wide. If you run many
.pathunits, you may need to increasefs.inotify.max_user_watches.
The Event-Driven Lifecycle
Understanding the full lifecycle is essential for writing correct path units. Here's the complete flow:
Step-by-Step Breakdown
| Step | What Happens | Your Action |
|---|---|---|
| 1. Create files | Write the .path and .service unit files | Place in /etc/systemd/system/ |
| 2. Reload | systemd reads the new files from disk | sudo systemctl daemon-reload |
| 3. Enable | systemd creates symlinks and starts the watch | sudo systemctl enable --now name.path |
| 4. Watch | systemd registers an inotify watch on the target path | Nothing — automatic |
| 5. Event | A filesystem change matches the watch directive | Drop a file, edit a config, etc. |
| 6. Activate | systemd starts the paired .service unit | Nothing — automatic |
| 7. Execute | The ExecStart command runs | Your script processes the event |
| 8. Log | stdout/stderr are captured by journald | journalctl -u name.service |
| 9. Re-check | systemd checks if the trigger condition still holds | Depends on directive |
| 10. Loop or wait | Re-triggers for PathExists/DirectoryNotEmpty, waits for PathChanged | See below |
The Re-trigger Problem
This is the most common source of bugs for beginners. Different watch directives have different re-trigger behavior:
| Directive | After Service Exits... | What You Must Do |
|---|---|---|
PathExists= | Checks if the path still exists → re-triggers if yes | Delete the file in your script |
PathExistsGlob= | Checks if any matches remain → re-triggers if yes | Delete or move all matches |
DirectoryNotEmpty= | Checks if directory is still non-empty → re-triggers if yes | Delete or move processed files |
PathChanged= | Waits for the next change event | Nothing — no loop risk |
PathModified= | Waits for the next write event | Nothing — no loop risk |
If you use PathExists= and your service does NOT delete the trigger file, systemd will:
- Detect the file exists → start the service
- Service exits → systemd re-checks → file still exists
- Start the service again → repeat forever
This can consume 100% CPU and flood your journal. Always clean up trigger files.
Where Path Units Live
System Units (All Users, Root Required)
| Location | Purpose | Who Manages It |
|---|---|---|
/etc/systemd/system/ | Admin-created custom units and overrides | root |
/usr/lib/systemd/system/ | Package-provided units (do not edit directly) | Package manager |
/etc/systemd/system/name.d/override.conf | Override a package unit without replacing it | root |
User Units (Per User, No Root Required)
| Location | Purpose |
|---|---|
~/.config/systemd/user/ | Per-user path and service units |
User units run inside the user's session and are managed with systemctl --user:
# Create the directory if it doesn't exist
mkdir -p ~/.config/systemd/user/
# Reload user units
systemctl --user daemon-reload
# Enable and start a user path unit
systemctl --user enable --now my-watcher.path
# Check status
systemctl --user status my-watcher.path
By default, user units stop when the user logs out. To keep them running even after logout:
sudo loginctl enable-linger "$USER"
How systemd.path Compares to Other Approaches
vs. Cron Polling
| Aspect | Cron | systemd.path |
|---|---|---|
| Latency | Up to 60 seconds | Milliseconds |
| CPU usage while idle | Wake every minute | Zero |
| Concurrency control | None (overlapping runs) | Built-in (oneshot queuing) |
| Logging | Manual (redirect to file) | journald native |
vs. inotifywait Loop
# DON'T DO THIS in production — fragile and hard to manage
while true; do
inotifywait -e close_write /var/www/drop/
/usr/local/bin/process.sh
done
| Aspect | inotifywait loop | systemd.path |
|---|---|---|
| Survives reboot | No (unless daemonized) | Yes (enabled on boot) |
| Error handling | Manual | systemd restart policies |
| Logging | Manual | journald |
| Resource limits | Manual | cgroups, RuntimeMaxSec, etc. |
| Installation | Requires inotify-tools | Built-in to systemd |
Your First Path Unit — Hands-On Walkthrough
If you haven't built a path unit before, walk through this 5-minute exercise to see the full lifecycle.
Step 1 — Create the Service Unit
The service defines what to run. This one lists files in a drop folder and removes them.
[Unit]
Description=Process files from the test drop folder
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo "[$(date +%%F\ %%T)] Processing:" && ls /tmp/drop && rm -f /tmp/drop/*'
Type=oneshottells systemd the process runs once and exits — the correct type for any path-triggered job.
Step 2 — Create the Path Unit
The path defines what to watch. This one fires whenever the directory is not empty.
[Unit]
Description=Watch /tmp/drop for incoming files
[Path]
DirectoryNotEmpty=/tmp/drop
MakeDirectory=yes
[Install]
WantedBy=paths.target
DirectoryNotEmpty=triggers whenever the directory contains at least one file.MakeDirectory=yescreates/tmp/dropif it doesn't exist.
Step 3 — Reload, Enable, and Verify
sudo systemctl daemon-reload
sudo systemctl enable --now test-drop.path
Step 4 — Confirm the Watch Is Active
systemctl status test-drop.path --no-pager
● test-drop.path - Watch /tmp/drop for incoming files
Loaded: loaded (/etc/systemd/system/test-drop.path; enabled)
Active: active (waiting) since Mon 2026-03-02 03:06:00 UTC; 5s ago
Triggers: ● test-drop.service
Watch: /tmp/drop (DirectoryNotEmpty)
Step 5 — Drop Files and Watch It Fire
touch /tmp/drop/test1.txt /tmp/drop/test2.txt
sleep 1
journalctl -u test-drop.service -n 10 --no-pager
Mar 02 03:06:05 vps sh[1201]: [2026-03-02 03:06:05] Processing:
Mar 02 03:06:05 vps sh[1201]: test1.txt
Mar 02 03:06:05 vps sh[1201]: test2.txt
Step 6 — Verify the Folder Is Empty
ls /tmp/drop # Should return nothing — files were removed by the service
You now know the complete lifecycle: create → reload → enable → verify → trigger → read logs. Every path unit you build in the rest of this documentation follows the same pattern.
Key Takeaways
- A
.pathunit always works with a.serviceunit — it never runs commands directly. - systemd uses the kernel's inotify subsystem — no polling, no CPU waste.
PathExists=andDirectoryNotEmpty=re-trigger after the service exits if the condition still holds.PathChanged=andPathModified=wait for the next event — no loop risk.- Always check both the
.pathstatus and the.servicelogs when debugging. - User units live in
~/.config/systemd/user/and needloginctl enable-lingerfor persistence.
What's Next
- Watch Directives — deep dive into all 5 directives and when to use each one.