Monitoring Files and Folders on Linux with systemd path units (and inotifywait for those without root)

In this post
In the previous post, macOS’s launchd watched files and directories with WatchPaths to fire scripts automatically when something changed. The model is reactive — instead of running a backup every hour or a conversion every five minutes, the system watches the path on disk and only runs the job when it detects an actual modification. No polling, no waste, no vulnerability window between the change and the action.
Linux has the same capability, but implemented differently and with more options. systemd offers path units — .path files that monitor filesystem paths and automatically activate an associated service when the condition is met. It is the direct equivalent of launchd’s WatchPaths, with the same declarative philosophy: you describe what to watch in a configuration file, the system handles the rest. For anyone working on servers or desktops with systemd, which at this point means practically every mainstream distribution, path units are the right tool.
But not everyone has systemd available. Minimal containers, distributions like Alpine or Void Linux, shared environments where the user has no control over system services, legacy machines — there are legitimate scenarios where systemd is not available or where the user simply cannot create units. For those cases, inotifywait from the inotify-tools package solves the problem directly in the shell, using the same kernel notification infrastructure (inotify) that systemd uses under the hood, but without needing a daemon, root, or a configuration file.
This post applies the same two scenarios from the previous post — reactive SQLite backup and automatic image optimization — on Linux, first with systemd path units and then with inotifywait. The image conversion script is already published; the focus here is on the trigger mechanics and configuration file construction.
systemd path units: the Linux version of WatchPaths#
Anyone who read the post about systemd timers already knows the pattern: systemd divides responsibilities between units of different types, and each type handles one aspect of the job. A timer (.timer) defines when a service (.service) should run. A path (.path) does the same thing, but the trigger is not the clock — it is the filesystem. The .path watches, the .service executes. Two files working together, the same way a timer and service work together for time-based scheduling.
The separation may seem bureaucratic compared to launchd, where a single plist contains both the trigger (WatchPaths) and the command to execute (ProgramArguments). In practice, the division brings the same advantage that already appeared with timers: the service can be tested independently of the path. You run systemctl start my-backup.service to verify the script works, and only then activate my-backup.path to put the monitoring into production. If the service fails, the problem is the script, not the trigger. If the path does not fire, the problem is the monitoring, not the script. Each piece can be diagnosed in isolation, which on systems that run without direct supervision — headless servers, VPSes, containers — makes a real difference when something breaks at three in the morning.
Change detection on Linux uses the kernel’s inotify subsystem, which is the equivalent of kqueue on macOS. inotify has been around since kernel 2.6.13 (2005, coincidentally the same year launchd appeared on macOS) and is the standard infrastructure for filesystem event notification on Linux. Tools like tail -f, Webpack’s hot-reload, Python’s watchdog, and systemd itself use inotify under the hood. There is no polling involved — the kernel notifies the watching process when the event occurs, with negligible latency and near-zero resource consumption while nothing changes.
How a .path + .service works#
PathChanged, PathModified, and PathExists#
launchd has a single key — WatchPaths — that fires on any modification. systemd is more granular and offers three different directives for path monitoring, each with its own semantics.
PathModified fires when the file’s content is changed — that is, when a write actually changes data in the file. It is the closest equivalent to launchd’s WatchPaths for monitoring individual files. If what matters is knowing that the SQLite database received new transactions or that a configuration file was edited, PathModified is the right directive.
PathChanged fires when the file is closed after being modified. The difference from PathModified is temporal: while PathModified can fire during the write (on each buffer flush, for example), PathChanged waits for the file to be closed before firing. For backup scripts, this difference matters — firing the backup while the file is still being written can result in an inconsistent copy. In practice, PathChanged is the safer choice for most cases because it guarantees the write is complete before activating the service.
PathExists fires when the specified path comes into existence. It does not monitor modifications — only creation. If the file already exists when the path unit is activated, the service fires immediately. It is useful for semaphore-type scenarios: a process creates a signal file when it finishes its work, and the path unit detects the presence of that file to start the next step. For this post’s scenarios, PathExists is not the right tool.
There is also PathExistsGlob, which works like PathExists but accepts glob patterns — *.csv, backup-*.sql, etc. It fires when any file matching the pattern comes into existence in the directory. It seems tempting for the image optimization scenario (monitoring *.png and *.jpg), but in practice it has the same limitation as PathExists: it only detects creation, not subsequent modification. If the file is created empty and filled afterward — as some editors do — the service may fire before the content is ready.
All three directives can monitor both files and directories. When the path is a directory, PathModified and PathChanged fire when the directory structure changes — creation, removal, or renaming of files inside it. The behavior is analogous to launchd’s WatchPaths with directories, including the same limitation: monitoring is not recursive. Subfolders are not watched automatically; each relevant path needs its own directive.
Multiple directives can coexist in the same .path. A single path unit can have a PathChanged for one file and a PathModified for another, and the associated service fires when any condition is met. This flexibility does not exist in launchd, where each plist has a single WatchPaths — although the WatchPaths array accepts multiple paths, the type of event monitored is always the same for all of them.
The relationship between .path and .service#
The link between the .path and the .service is by naming convention: a path unit called my-backup.path automatically activates the service my-backup.service, without needing any explicit directive to connect the two. If for some reason the service has a different name, the Unit= directive in the [Path] section allows specifying which service to activate, but in practice keeping the same name is simpler and more readable.
When the path unit detects a change, it activates the associated service — which runs, executes the script, and terminates. The path unit continues watching. On the next change, the service is activated again. The cycle repeats indefinitely as long as the path unit is enabled. It is exactly the same model as launchd: the trigger watches, the script executes and terminates, the trigger continues watching.
One detail that differs from launchd: if the service is still running when a new change is detected, systemd does not queue a second execution. The change is registered, but the service is not activated again until the current execution finishes. In practice, this means that if the backup script takes two minutes and the database is modified three times during that period, the service runs once when the current execution finishes — not three times. For scripts that sweep the entire directory on each execution (like the image optimizer), this behavior is desirable. For scripts that process a single event per execution, it means intermediate modifications may be “grouped” into a single activation.
The minimal form of the two files together is:
~/.config/systemd/user/my-backup.path:
[Path]
PathChanged=/home/janio/data/file.db
[Install]
WantedBy=default.target
~/.config/systemd/user/my-backup.service:
[Service]
ExecStart=/home/janio/bin/my-script.sh
Two files, four lines of configuration total (not counting the [Install] section). The .path says what to watch; the .service says what to run. Activation is done with a single command:
systemctl --user enable --now my-backup.path
The --user is the detail that makes everything work without root, and deserves its own section.
User units: no root, no problem#
All units shown in this post live in ~/.config/systemd/user/ and are managed with systemctl --user. They do not need root to create, do not need root to activate, and run with the logged-in user’s permissions. It is the direct equivalent of LaunchAgents in ~/Library/LaunchAgents/ on macOS — personal automation, without escalating privileges, without touching the system configuration.
User units have, by default, one important limitation: they only run while the user has an active session. If the user logs out, the units stop. On desktops with a permanent graphical login this is rarely a problem, but on servers accessed via SSH it makes a difference. The solution is loginctl enable-linger, which allows the user’s units to keep running even without an active session. On macOS, this problem does not exist — personal LaunchAgents remain active while the user is logged into the graphical session, and the concept of “logout” on a personal Mac is rare enough not to be a practical concern.
For this post’s scenarios, user units are the right choice. The SQLite database is a user file, the images belong to the user, the scripts run in the user’s context, and the backup goes to a remote configured in the user’s rclone. Nothing here needs privileged access, and running as root would be unnecessary overkill — as well as a risk, since any bug in the script would have permission to damage the entire system rather than just the user’s files.
Scenario 1: Reactive SQLite backup#
The .path#
~/.config/systemd/user/fuqu-backup.path:
[Path]
PathChanged=/home/janio/.local/share/fuqu/fuqu.db
[Install]
WantedBy=default.target
One directive, one path. PathChanged instead of PathModified is deliberate: since SQLite in WAL mode makes multiple sequential writes (the WAL grows, the checkpoint consolidates), PathModified could fire the service multiple times during a single write operation. PathChanged waits for the file to be closed, which in practice means the service is only activated after the transaction finishes and the database is in a consistent state.
In the previous post about launchd, WatchPaths pointed at the fuqu.db file rather than the directory, because monitoring the directory might not capture writes made directly to the file by SQLite. The same reasoning applies here: PathChanged points at the database itself, not at ~/.local/share/fuqu/.
The .service#
~/.config/systemd/user/fuqu-backup.service:
[Service]
Type=oneshot
ExecStart=/home/janio/bin/fuqu-backup.sh
Environment=PATH=/usr/local/bin:/usr/bin:/bin
Type=oneshot tells systemd that the service runs a task and terminates — it is not a daemon that stays running. Without this directive, systemd assumes Type=simple and expects the process to remain active; when the script finishes, systemd would interpret the exit as a failure. oneshot is the correct type for backup scripts, conversions, syncs, and any job that runs, does its work, and exits.
Environment=PATH=... solves the same problem as EnvironmentVariables in the launchd plist: systemd does not inherit the user’s shell PATH. If rclone was installed in /usr/local/bin or via snap in /snap/bin, the path needs to be explicit. An alternative is to use rclone’s absolute path directly in the script, but defining the PATH in the service keeps the script portable across machines where the binary may be in different locations.
Logging, unlike launchd where we needed to declare StandardOutPath and StandardErrorPath in the plist, comes for free. systemd captures stdout and stderr automatically and directs them to the journal. To see the output of the last backup:
journalctl --user -u fuqu-backup.service -n 50
To follow in real time while testing:
journalctl --user -u fuqu-backup.service -f
No log file to rotate, no disk silently filling up with months of accumulated output. The journal handles retention and space automatically — one of the concrete advantages of systemd over launchd’s manual log model.
Activation of both files is a single command:
systemctl --user enable --now fuqu-backup.path
enable registers the path unit to start automatically on login. --now activates immediately without waiting for the next login. From this point on, any modification to fuqu.db fires fuqu-backup.service. To verify that monitoring is active:
systemctl --user status fuqu-backup.path
Throttle: why the script still needs to protect itself#
systemd does not have an automatic throttle equivalent to launchd’s 10-second interval. If the database is modified, the service runs. If the database is modified again one second later and the service has already finished, it runs again. There is no minimum forced interval between executions.
This makes the throttle in the script even more necessary than on macOS. The mechanism is the same described in the previous post: the script writes a timestamp to a control file after each backup, and on the next execution checks whether the minimum interval has passed before doing any work. If it hasn’t, it exits with code 0 and systemd records the execution as successful.
systemd has a RateLimitIntervalSec directive combined with RateLimitBurst in the [Unit] section that could, in theory, limit the activation frequency. But these directives control systemd’s own rate limit for service activations — when the limit is reached, systemd temporarily disables the path unit, which means modifications during that period are silently ignored. It is not a throttle with a guarantee of eventual execution; it is a circuit breaker that discards events. For a backup where no modification should be lost, the in-script throttle is the correct approach: the execution happens, checks that it is too soon, and exits cleanly — but the path unit remains active and ready to fire on the next change.
Scenario 2: Automatic image optimization#
The .path#
~/.config/systemd/user/image-optimizer.path:
[Path]
PathChanged=/home/janio/Pictures/optimize
[Install]
WantedBy=default.target
Here PathChanged points at a directory, not a file. The behavior is as expected: systemd fires the service when the directory structure changes — a file created, removed, or renamed. Saving a PNG to ~/Pictures/optimize/ is a file creation, which changes the directory, which fires the path unit.
The temptation to use PathExistsGlob with patterns like *.png and *.jpg comes up naturally here, but it does not solve the problem. PathExistsGlob fires when a file matching the pattern comes into existence, and fires only once — after the service runs, the path unit does not react to new files matching the same glob until it is restarted. It is a directive designed for semaphore scenarios (“wait until this file appears”), not for continuous monitoring. PathChanged on the directory is the correct choice for a flow where images can arrive at any time and in any quantity.
The .service#
~/.config/systemd/user/image-optimizer.service:
[Service]
Type=oneshot
ExecStart=/home/janio/bin/optimize-images.sh
Environment=PATH=/usr/local/bin:/usr/bin:/bin
The structure is identical to the backup service. Type=oneshot because the script processes images and terminates. The PATH includes /usr/local/bin where cwebp and avifenc may be — on Linux, unlike macOS with Homebrew in /opt/homebrew/bin, these packages typically come from the distribution’s package manager and install to /usr/bin or /usr/local/bin. On Debian-based distributions, the packages are webp and libavif-bin:
sudo apt install webp libavif-bin
Logging follows the same model: stdout and stderr go to the journal automatically. To check the result of a conversion:
journalctl --user -u image-optimizer.service -n 20
Activation:
systemctl --user enable --now image-optimizer.path
From this point on, any PNG or JPG saved to ~/Pictures/optimize/ fires the conversion script.
The same care with loops#
The loop problem described in the launchd post applies equally here, with the same cause and the same solution. If the script converts photo.png to photo.avif inside the same monitored directory, the creation of the .avif is a directory modification, which fires the path unit, which runs the script again. The script needs to filter by extension — process only .png, .jpg, and .jpeg, ignore everything else — or the separation into two directories (input and output) eliminates the problem at its root.
On systemd, there is an aggravating factor that launchd does not have: since there is no automatic 10-second throttle between executions, a loop caused by a missing filter can be more aggressive. The script creates the .avif, the path unit fires immediately, the script runs again, finds nothing to process (if the filter is correct) and exits. But if the filter is not correct and the script tries to reprocess the .avif, the sequence repeats with no external brake until systemd’s default RateLimitBurst intervenes and disables the path unit — which stops the loop but also kills legitimate monitoring.
The correct protection is twofold: the extension filter in the script ensures the execution ends without side effects when there is no real work to do, and a quick exit 0 at the beginning of the script when find returns no eligible files prevents systemd from even logging significant activity in the journal. The path unit fires, the script looks at the directory, finds no PNGs or JPGs, and exits in milliseconds. The cost of an empty execution is negligible; the cost of an unprotected loop can be a disabled path unit and images that stop being converted without warning.
For those without systemd: inotifywait#
What it is and where it comes from#
systemd path units and launchd’s WatchPaths are declarative abstractions over kernel mechanisms — inotify on Linux, kqueue on macOS. They hide the complexity behind configuration files and manage the process lifecycle automatically. But the abstraction requires the corresponding init system. On machines without systemd — minimal Docker containers, Alpine Linux, distributions using OpenRC or runit, shared servers where the user does not control services — path units do not exist.
inotifywait is the way to access the kernel’s inotify directly from the shell, without intermediaries. It is part of the inotify-tools package, available in the repositories of virtually every Linux distribution. On Debian and Ubuntu:
sudo apt install inotify-tools
On Alpine:
apk add inotify-tools
inotifywait blocks until an event occurs on the monitored path and then prints the event and exits — or, with the -m (monitor) flag, keeps running and printing events indefinitely. It is not a daemon, does not need a configuration file, does not need root. It is a command that watches and reports, and the reaction logic is up to whoever consumes its output — typically a while read in a bash script.
Event granularity is greater than anything available in launchd or systemd path units. inotify distinguishes between create, modify, close_write, delete, moved_to, moved_from, attrib, and more than a dozen other event types. inotifywait allows filtering by any combination of them with the -e flag. Where systemd’s PathChanged groups several events into a “the file was closed after modification” semantic, inotifywait lets you choose exactly which events matter — and ignore the rest.
Scenario 1 with inotifywait#
For the reactive SQLite backup, inotifywait monitors the fuqu.db file and fires the backup script when the file is modified and closed:
inotifywait -m -e close_write /home/janio/.local/share/fuqu/fuqu.db |
while read dir event file; do
/home/janio/bin/fuqu-backup.sh
done
The close_write event is the right choice here — it fires when the file is closed after being opened for writing. It is the functional equivalent of systemd’s PathChanged: it guarantees the SQLite transaction finished before starting the backup. Using modify instead of close_write would fire the script on each buffer flush, potentially multiple times during a single write operation.
The command blocks in the terminal. To run in the background persistently, the options are putting it inside a script and running with nohup, inside a tmux or screen session, or — ironically — inside a systemd service or a supervisor like runit or s6. Without a supervisor, if the process dies (OOM, accidental kill, terminal crash), monitoring stops and nobody notices. This is the fundamental disadvantage of inotifywait compared to launchd and systemd: it does the monitoring, but does not take care of itself.
The throttle works the same way as in the previous scenarios — the backup script checks the timestamp of the last backup before executing. The difference is that with inotifywait the throttle is even more important, because there is no external mechanism limiting the call frequency. Every close_write on the database fires a script invocation, and during active FUQU use that can mean dozens of invocations per minute. Without the in-script throttle, each one would try to connect to Backblaze B2.
Scenario 2 with inotifywait#
For image optimization, inotifywait monitors the directory and reacts to new file creation:
inotifywait -m -e close_write --include '\.(png|jpg|jpeg)$' \
/home/janio/Pictures/optimize/ |
while read dir event file; do
/home/janio/bin/optimize-images.sh
done
The --include with a regular expression filters events before they reach the while read. Only files with .png, .jpg, or .jpeg extensions fire the script. An .avif created by the conversion script itself generates a close_write event in the directory, but inotifywait ignores it because it does not match the filter. The loop problem that needed to be handled in the script for both launchd and systemd is solved here in the monitoring command itself — one layer earlier.
This is a concrete advantage of inotifywait over the declarative alternatives: the filename pattern filter happens in the monitoring tool, not in the script that processes the files. launchd’s WatchPaths does not accept filters — it fires for any change, and the script decides what to do. systemd’s PathChanged also does not filter by filename. inotifywait allows being selective before the script is even called, which reduces unnecessary executions and simplifies script logic.
The close_write event instead of create solves the partially written files problem discussed in the previous post. create fires the instant the file appears in the directory, before the content is fully written. A 10 MB PNG being saved by a browser fires create when the download starts and close_write when it finishes. Monitoring close_write ensures the file is complete before the script tries to convert it.
Limitations and when it is worth it#
inotifywait is powerful as a monitoring mechanism but fragile as automation infrastructure. The limitations are all related to the absence of a supervisor:
The process needs to be running to monitor. If it dies, monitoring stops. There is no equivalent of systemd’s enable that guarantees automatic restart on boot or after a crash. It is up to the user to ensure inotifywait is running — via nohup, tmux, crontab with @reboot, or any other external persistence mechanism.
There is no recovery of lost events. If inotifywait was not running when the SQLite database was modified, the modification goes unnoticed. launchd with WatchPaths checks the state of monitored paths when the agent is loaded and fires if something changed during the inactive period. systemd does the same when the path unit is activated. inotifywait has no such memory — it observes from the moment it starts running, and everything that happened before does not exist.
There is no integrated logging. Output goes to stdout, and if nobody captured it, it is gone. Redirecting to a log file is trivial (>> /var/log/my-monitor.log 2>&1), but rotation and retention are up to the user.
That said, inotifywait is worth it in specific contexts: environments without systemd where installing an alternative init system would be disproportionate to the problem, throwaway scripts that need to monitor a directory for a few hours during a migration or import, and situations where event granularity justifies the extra complexity — filtering by event type and filename pattern directly in the monitoring command is something neither launchd nor systemd offer with the same precision. For permanent automation on a machine with systemd, path units are the right choice. For a one-off monitoring task or in a restricted environment, inotifywait does the job with zero dependencies beyond the kernel.
launchd, systemd, and inotifywait: quick comparison#
Three posts, three tools, the same goal. The table below summarizes the practical differences for anyone who needs to choose — or for those who use more than one system and want to know what changes.
| launchd (WatchPaths) | systemd (path units) | inotifywait | |
|---|---|---|---|
| System | macOS | Linux with systemd | Any Linux |
| Configuration | plist XML | .path + .service (INI) | Shell command |
| Needs root | No (LaunchAgent) | No (user unit) | No |
| Kernel mechanism | kqueue | inotify | inotify |
| Event type filter | No | Partial (PathChanged vs PathModified) | Yes (any inotify event) |
| Filename filter | No | No (except PathExistsGlob) | Yes (–include / –exclude) |
| Recursive monitoring | No | No | Yes (-r) |
| Automatic throttle | Yes (10s, adjustable upward) | No (RateLimitBurst disables the path) | No |
| Lost event recovery | Yes (checks state on load) | Yes (checks state on activation) | No |
| Logging | Manual (StandardOutPath) | Automatic (journal) | Manual (stdout redirection) |
| Process supervision | Automatic (launchd restarts) | Automatic (systemd restarts) | None (needs external supervisor) |
| Independent testing | launchctl start | systemctl start .service | Run the script directly |
The inotifywait column stands out at both extremes: the greatest event granularity and filters, but no supervision or recovery. It is the most powerful tool as an observation mechanism and the most fragile as automation infrastructure. launchd and systemd converge on most practical aspects, with specific differences — launchd’s automatic throttle, systemd’s integrated logging, systemd’s event granularity — that reflect the different philosophies of the two systems.
For the two scenarios in this post and the previous one — reactive backup and image optimization — any of the three tools solves the problem. The choice is dictated by the operating system and available access level, not by the tool’s technical capability. On Mac, launchd. On Linux with systemd, path units. On Linux without systemd or without control over services, inotifywait.
What is left for future posts#
This post and the previous one covered the same territory — reactive file and directory monitoring — on the two operating systems a sysadmin probably uses day to day. The launchd plists, systemd path units, and inotifywait are three ways of saying the same thing: “when this changes, run that.” The trigger mechanics are covered.
What remains are the scripts the triggers fire. The SQLite backup script needs to handle WAL checkpoint, staging directory, rclone configured with a Backblaze B2 remote, and the throttle mechanism that appeared as a concept in both posts but has not yet become code. The image conversion script is already done — with encoder fallback, protection against incomplete files, and output format and original file destination configuration.
These are different problems from monitoring — less about system configuration and more about application logic — and they deserve their own space to be treated with the detail they need. Future posts will deliver the complete code for each.
Sysadmin, 53, Brazilian working from home for the world. Manages Linux servers, LXC containers, and cats that won't get off the keyboard.