Skip to main content

How systemd.path Works

Learning Focus

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:

  1. Path unit (name.path) — defines what to watch and which event to react to.
  2. Service unit (name.service) — defines what to run when the event fires.
Core Rule

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:

UnitWhat It Tells You
process-drop.pathWatch status (active (waiting) or inactive), directive in use, last trigger time, enabled state
process-drop.serviceExit code, stdout/stderr output, runtime duration, failure reason
check-both-states.sh
# Always check BOTH units when debugging
systemctl status process-drop.path --no-pager
systemctl status process-drop.service --no-pager
example-path-status.txt
● 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)
example-service-status.txt
● 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 .path units, you may need to increase fs.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​

StepWhat HappensYour Action
1. Create filesWrite the .path and .service unit filesPlace in /etc/systemd/system/
2. Reloadsystemd reads the new files from disksudo systemctl daemon-reload
3. Enablesystemd creates symlinks and starts the watchsudo systemctl enable --now name.path
4. Watchsystemd registers an inotify watch on the target pathNothing — automatic
5. EventA filesystem change matches the watch directiveDrop a file, edit a config, etc.
6. Activatesystemd starts the paired .service unitNothing — automatic
7. ExecuteThe ExecStart command runsYour script processes the event
8. Logstdout/stderr are captured by journaldjournalctl -u name.service
9. Re-checksystemd checks if the trigger condition still holdsDepends on directive
10. Loop or waitRe-triggers for PathExists/DirectoryNotEmpty, waits for PathChangedSee below

The Re-trigger Problem​

This is the most common source of bugs for beginners. Different watch directives have different re-trigger behavior:

DirectiveAfter Service Exits...What You Must Do
PathExists=Checks if the path still exists → re-triggers if yesDelete the file in your script
PathExistsGlob=Checks if any matches remain → re-triggers if yesDelete or move all matches
DirectoryNotEmpty=Checks if directory is still non-empty → re-triggers if yesDelete or move processed files
PathChanged=Waits for the next change eventNothing — no loop risk
PathModified=Waits for the next write eventNothing — no loop risk
The Infinite Loop Trap

If you use PathExists= and your service does NOT delete the trigger file, systemd will:

  1. Detect the file exists → start the service
  2. Service exits → systemd re-checks → file still exists
  3. 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)​

LocationPurposeWho Manages It
/etc/systemd/system/Admin-created custom units and overridesroot
/usr/lib/systemd/system/Package-provided units (do not edit directly)Package manager
/etc/systemd/system/name.d/override.confOverride a package unit without replacing itroot

User Units (Per User, No Root Required)​

LocationPurpose
~/.config/systemd/user/Per-user path and service units

User units run inside the user's session and are managed with systemctl --user:

user-unit-management.sh
# 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
User Unit Persistence

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​

AspectCronsystemd.path
LatencyUp to 60 secondsMilliseconds
CPU usage while idleWake every minuteZero
Concurrency controlNone (overlapping runs)Built-in (oneshot queuing)
LoggingManual (redirect to file)journald native

vs. inotifywait Loop​

fragile-inotifywait-loop.sh
# 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
Aspectinotifywait loopsystemd.path
Survives rebootNo (unless daemonized)Yes (enabled on boot)
Error handlingManualsystemd restart policies
LoggingManualjournald
Resource limitsManualcgroups, RuntimeMaxSec, etc.
InstallationRequires inotify-toolsBuilt-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.

/etc/systemd/system/test-drop.service
[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=oneshot tells 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.

/etc/systemd/system/test-drop.path
[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=yes creates /tmp/drop if it doesn't exist.

Step 3 — Reload, Enable, and Verify​

activate-test-drop.sh
sudo systemctl daemon-reload
sudo systemctl enable --now test-drop.path

Step 4 — Confirm the Watch Is Active​

check-test-drop.sh
systemctl status test-drop.path --no-pager
expected-output.txt
● 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​

test-trigger.sh
touch /tmp/drop/test1.txt /tmp/drop/test2.txt
sleep 1
journalctl -u test-drop.service -n 10 --no-pager
expected-output.txt
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​

verify-empty.sh
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 .path unit always works with a .service unit — it never runs commands directly.
  • systemd uses the kernel's inotify subsystem — no polling, no CPU waste.
  • PathExists= and DirectoryNotEmpty= re-trigger after the service exits if the condition still holds.
  • PathChanged= and PathModified= wait for the next event — no loop risk.
  • Always check both the .path status and the .service logs when debugging.
  • User units live in ~/.config/systemd/user/ and need loginctl enable-linger for persistence.

What's Next​

  • Watch Directives — deep dive into all 5 directives and when to use each one.