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.