systemd’s path units let you watch filesystem events and trigger actions, but they’re a lot more powerful (and sometimes confusing) than just "run this when a file changes."

Let’s see a real-world example. Imagine you have a directory where files appear, and you want to process each one as it arrives.

# /etc/systemd/system/process-new-file@.path
[Unit]
Description=Process new file %I

[Path]
PathExists=/var/spool/new_files/%I

[Install]
WantedBy=multi-user.target
# /etc/systemd/system/process-new-file@.service
[Unit]
Description=Service to process %I

[Service]
Type=oneshot
ExecStart=/usr/local/bin/process_file.sh %I
User=appuser
Group=appgroup

Now, if you create a file named /var/spool/new_files/my_data.txt, systemd will notice PathExists matching /var/spool/new_files/my_data.txt and trigger process-new-file@my_data.txt.service. The %I in the .path unit is crucial; it captures the filename part and passes it as an argument to the corresponding .service unit.

The magic here is that systemd doesn’t just watch files; it actively manages the lifecycle of these triggers. When the .service unit runs and successfully completes, systemd automatically disables the associated .path unit. This prevents it from triggering again for the same file. If the service fails, the path unit remains enabled, allowing retries.

Here’s how you’d enable and start this:

  1. Enable the path units:
    sudo systemctl enable process-new-file@my_data.txt.path
    
  2. Start the path units:
    sudo systemctl start process-new-file@my_data.txt.path
    

Now, when /var/spool/new_files/my_data.txt is created, the service will run. If you create another file, /var/spool/new_files/another_file.csv, you’d enable and start its corresponding path unit:

sudo systemctl enable process-new-file@another_file.csv.path
sudo systemctl start process-new-file@another_file.csv.path

This pattern, using templated units (@.path, @.service), is the idiomatic way to handle multiple, distinct file-based triggers. Each file gets its own .path unit instance.

The core problem systemd path units solve is reliable, event-driven automation without a constant polling loop. Traditional cron jobs might poll a directory every minute, potentially missing rapid file creation/deletion or processing files multiple times. systemd uses kernel-level inotify (or similar mechanisms) for efficient watching, and its internal state management ensures each event is handled precisely once.

Let’s look at the configuration options:

  • PathExists: Triggers when a file or directory exists at the specified path.
  • PathExistsGlob: Triggers when any file matching the glob pattern exists. This is often used for directories where you want to trigger on any new file.
  • PathModified: Triggers when the file at the specified path has been modified (e.g., mtime changed).
  • PathChanged: Triggers on modification, access, or creation/deletion. More comprehensive than PathModified.
  • PathUnitChanged: Triggers when the file’s metadata changes (permissions, ownership, etc.), but not its content.

Consider PathExistsGlob. If you want to process any file dropped into /var/spool/incoming/, you’d configure your .path unit like this:

# /etc/systemd/system/process-incoming.path
[Unit]
Description=Process any new file in incoming directory

[Path]
PathExistsGlob=/var/spool/incoming/*

[Install]
WantedBy=multi-user.target

When a file like /var/spool/incoming/report_20231027.pdf appears, PathExistsGlob matches. The corresponding service unit, process-incoming.service, would be activated. Crucially, systemd uses the specific filename that matched the glob to instantiate the service. If you had process-incoming@.service, systemd would try to start process-incoming@report_20231027.pdf.service.

The IsBase64 option is a less commonly known but powerful feature. If set to yes in a .path unit, systemd treats the glob pattern as a base64-encoded string. This is useful for creating path units that can trigger on filenames containing unusual characters that might otherwise interfere with glob expansion or systemd’s parsing.

When PathExistsGlob or PathModified is used with templated units (@.path), systemd instantiates the service using the exact filename that triggered the path unit. For example, if /var/log/app/error.log is created and PathExistsGlob=/var/log/app/* is used in my-log-processor@.path, systemd will activate my-log-processor@error.log.service. This is why templated units are almost always paired with globbing or specific path matching for this use case.

The path unit itself doesn’t run a command; it only triggers the associated service. The service unit is where you define ExecStart. The Type=oneshot is common for services triggered by path units because they typically perform a single task and then exit.

The most surprising thing about systemd path units is that they are designed to be idempotent by default. Once a service triggered by a path unit successfully completes, the path unit is automatically disabled. This means if you create /var/spool/new_files/processed_file.txt again, process-new-file@processed_file.txt.path will no longer trigger the service because it’s been disabled. This prevents runaway processes and ensures each file is processed only once under normal circumstances.

What most people don’t realize is how systemd handles the "state" of the path unit. When a path unit triggers its corresponding service, and that service unit exits with a status code of 0 (success), systemd automatically runs systemctl disable <path_unit_name>.path. If the service exits with a non-zero status code (failure), the path unit remains enabled, allowing for retries. This is a silent but critical piece of behavior for reliable automation.

The next concept to explore is how to handle dependencies and ordering between path units and other systemd units, particularly when complex workflows are involved.

Want structured learning?

Take the full Systemd course →