Run a Document Watcher as a systemd Service
The folder watcher runs fine in a terminal, so it gets started with nohup python watcher.py & on the document server. It survives until the next reboot, or until it crashes on a malformed PDF at 2 a.m. and nobody notices for three days. Turning it into a systemd service is the obvious next step, and the first unit file fails in new ways: status=203/EXEC, ModuleNotFoundError: No module named 'watchdog', a watcher that cannot see files its own user just wrote, or a service that takes 90 seconds to stop and loses the file it was processing.
× doc-watcher.service - Document drop-folder watcher
Active: failed (Result: exit-code) since Thu 2026-09-17 09:02:11 UTC
Process: 4121 ExecStart=python watcher.py /srv/drop/incoming (code=exited, status=203/EXEC)
Root Cause
systemd starts processes in a deliberately minimal environment, which is exactly what makes services reproducible and exactly what breaks scripts written for an interactive shell. There is no activated virtualenv, no PATH entries from .bashrc, no working directory unless one is set, and no environment variables except those declared in the unit. status=203/EXEC means the ExecStart program could not be executed — typically a relative path or a bare python that systemd cannot resolve. ModuleNotFoundError means the system interpreter ran instead of the virtualenv's. Permission problems appear because the service runs as a different user, often with a sandboxed file system when hardening options are copied from examples. And slow, lossy stops happen because systemd sends SIGTERM and waits, while a script that only handles KeyboardInterrupt ignores the signal until the 90-second default timeout ends with SIGKILL mid-file.
Minimal Diagnostic
Ask systemd what it actually ran, with which environment, and read the service's own output. These three commands answer almost every service failure.
systemctl status doc-watcher.service --no-pager
systemctl show doc-watcher.service -p ExecStart -p User -p WorkingDirectory -p Environment -p EnvironmentFiles
journalctl -u doc-watcher.service -n 50 --no-pager
To reproduce the service's environment interactively, run the same command line as the service user with an empty environment:
sudo -u docbot env -i PATH=/usr/bin:/bin /opt/doc-watcher/.venv/bin/python -c \
"import sys, watchdog; print(sys.executable, watchdog.__version__)"
sudo -u docbot test -w /srv/drop/incoming && echo "writable" || echo "NOT writable"
If the import fails here, the service will fail the same way; if the directory is not writable for the service user, moving processed files will fail.
Fix: A Complete Unit File and a Signal-Aware Script
Install the application into a fixed directory with its own virtualenv, run it as a dedicated user, and describe everything the process needs in the unit. Comments mark each line that fixes one of the failures above.
sudo useradd --system --home /opt/doc-watcher --shell /usr/sbin/nologin docbot
sudo mkdir -p /opt/doc-watcher /srv/drop/{incoming,processed,failed} /var/lib/doc-watcher
sudo python3 -m venv /opt/doc-watcher/.venv
sudo /opt/doc-watcher/.venv/bin/pip install "watchdog>=4.0" pdfplumber openpyxl
sudo cp watcher.py /opt/doc-watcher/
sudo chown -R docbot:docbot /srv/drop /var/lib/doc-watcher
# /etc/systemd/system/doc-watcher.service
[Unit]
Description=Document drop-folder watcher
After=network-online.target remote-fs.target
Wants=network-online.target
[Service]
Type=simple
User=docbot
Group=docbot
WorkingDirectory=/opt/doc-watcher
# absolute interpreter path from the virtualenv: fixes 203/EXEC and ModuleNotFoundError
ExecStart=/opt/doc-watcher/.venv/bin/python -u watcher.py /srv/drop/incoming
# secrets and settings live outside the unit, readable only by root and the service
EnvironmentFile=/etc/doc-watcher/env
Environment=PYTHONUNBUFFERED=1
# restart on crashes, but stop retrying if it crash-loops
Restart=on-failure
RestartSec=10
StartLimitIntervalSec=300
StartLimitBurst=5
# give the script time to finish the current file after SIGTERM
KillSignal=SIGTERM
TimeoutStopSec=60
# hardening that still allows the folders the watcher needs
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/srv/drop /var/lib/doc-watcher
MemoryMax=1G
[Install]
WantedBy=multi-user.target
ProtectSystem=strict makes the whole file system read-only for the service except the paths listed in ReadWritePaths — copy it from an example without that line and every file move fails with Read-only file system. -u and PYTHONUNBUFFERED=1 make log lines reach the journal immediately instead of in 4 KB bursts. StartLimitBurst stops a service that crashes on start from restarting forever and filling the journal. Put remote-fs.target in After= whenever the watched folder is a network mount, so the watcher does not start against an empty mount point.
The script needs to turn SIGTERM into a clean stop: finish the current file, stop the observer, close state.
# pip install "watchdog>=4.0"
import logging
import signal
import sys
import threading
from pathlib import Path
log = logging.getLogger("doc-watcher")
stop = threading.Event()
def handle_signal(signum, frame) -> None:
log.info("received %s, finishing current file and stopping", signal.Signals(signum).name)
stop.set() # changed: cooperative shutdown
def main() -> int:
logging.basicConfig(level=logging.INFO, stream=sys.stdout, # changed: stdout goes to journald
format="%(levelname)s %(name)s %(message)s")
signal.signal(signal.SIGTERM, handle_signal) # changed: systemd stop
signal.signal(signal.SIGINT, handle_signal) # Ctrl+C when run by hand
folder = Path(sys.argv[1])
if not folder.is_dir():
log.error("%s is not a directory (mount missing?)", folder)
return 2 # non-zero: systemd counts a failure
observer, worker_thread = start_watcher(folder, stop) # observer + worker from the watcher guide
try:
while not stop.wait(timeout=5):
if not worker_thread.is_alive():
log.error("worker thread died; exiting so systemd restarts the service")
return 1
finally:
observer.stop()
observer.join(timeout=20)
worker_thread.join(timeout=30) # changed: let the current file finish
log.info("stopped cleanly")
return 0
if __name__ == "__main__":
sys.exit(main())
Logging without timestamps is intentional: journald adds its own, and duplicated timestamps make logs harder to read. Returning a non-zero exit code when the worker thread dies turns a silently stuck watcher into a restart that shows up in systemctl status.
Enable and start it:
sudo mkdir -p /etc/doc-watcher && sudo install -m 600 -o root -g root env.example /etc/doc-watcher/env
sudo systemctl daemon-reload
sudo systemctl enable --now doc-watcher.service
journalctl -u doc-watcher.service -f
Variant Fix 1: Network Share Not Mounted Yet
A watcher on /mnt/scans that starts before the SMB mount exists watches the empty local directory under the mount point, sees nothing, and never recovers. Declare the mount as a dependency so systemd starts the watcher only when it is present:
# additions to [Unit]
RequiresMountsFor=/mnt/scans
# /etc/fstab — mount on first access and tolerate a slow file server at boot
//fileserver/scans /mnt/scans cifs credentials=/etc/doc-watcher/smb,uid=docbot,gid=docbot,_netdev,x-systemd.automount,x-systemd.mount-timeout=30 0 0
RequiresMountsFor adds both ordering and a requirement on the mount unit, and _netdev marks the mount as needing the network. Pair this with the polling observer, because SMB mounts do not deliver change notifications to the client — see Watching Folders for Incoming Documents. The script's own is_dir() check remains a useful second line of defence.
Variant Fix 2: Get Told When It Fails
Restart=on-failure hides crashes by recovering from them. Add a failure hook so repeated failures notify someone, using a templated helper unit:
# addition to doc-watcher.service [Unit]
OnFailure=notify-failure@%n.service
# /etc/systemd/system/[email protected]
[Unit]
Description=Send failure notice for %i
[Service]
Type=oneshot
User=docbot
EnvironmentFile=/etc/doc-watcher/env
ExecStart=/opt/doc-watcher/.venv/bin/python /opt/doc-watcher/notify.py "%i"
# /opt/doc-watcher/notify.py — pip install requests
import os
import subprocess
import sys
import requests
unit = sys.argv[1]
logs = subprocess.run(["journalctl", "-u", unit, "-n", "30", "--no-pager"],
capture_output=True, text=True).stdout
try:
requests.post(os.environ["ALERT_WEBHOOK_URL"], timeout=10,
json={"text": f"{unit} failed on {os.uname().nodename}\n```{logs[-3000:]}```"})
except (KeyError, requests.RequestException) as exc:
print(f"alert not sent: {exc}", file=sys.stderr)
OnFailure fires when the unit enters the failed state — after start limits are exhausted, or on a failure without restart — so a single transient crash that restarts cleanly does not page anyone. The service user needs membership in the systemd-journal group to read the journal. The same alerting pattern for scheduled jobs is in add retries and failure alerts to automation jobs.
Operating the Service Day to Day
A few commands cover almost all routine work, and writing them into the job's runbook saves the next person from searching:
# is it healthy, and since when?
systemctl status doc-watcher.service --no-pager
# follow the log, or show errors from the last day only
journalctl -u doc-watcher.service -f
journalctl -u doc-watcher.service --since "24 hours ago" -p err --no-pager
# deploy a new version: install, then restart (graceful stop, then start)
sudo cp watcher.py /opt/doc-watcher/ && sudo systemctl restart doc-watcher.service
# after editing the unit file
sudo systemctl daemon-reload && sudo systemctl restart doc-watcher.service
# see effective settings including drop-in overrides
systemctl cat doc-watcher.service
Use drop-in overrides (sudo systemctl edit doc-watcher.service) for per-server changes such as a different watched path or memory limit, rather than editing the packaged unit; they survive redeployment of the main unit file and show up clearly in systemctl cat.
Verification
Check the three behaviours that matter: it starts at boot, it restarts after a crash, and it stops gracefully without losing work.
# starts at boot
systemctl is-enabled doc-watcher.service # expect: enabled
# restarts after a crash
PID=$(systemctl show -p MainPID --value doc-watcher.service)
sudo kill -9 "$PID"; sleep 15
systemctl show -p NRestarts --value doc-watcher.service # expect: incremented
systemctl is-active doc-watcher.service # expect: active
# stops gracefully while a file is being processed
cp big-sample.pdf /srv/drop/incoming/ && sleep 1
time sudo systemctl stop doc-watcher.service # expect: well under TimeoutStopSec
journalctl -u doc-watcher.service -n 5 --no-pager | grep "stopped cleanly"
ls /srv/drop/processed/*/ | grep big-sample # the file finished, not half-processed
sudo systemctl start doc-watcher.service
kill -9 simulates a hard crash that no signal handler can intercept, which is exactly the case Restart=on-failure exists for. If the stop takes close to the timeout and the journal lacks the "stopped cleanly" line, the signal handler is not wired up or the worker ignores the stop event.
FAQ
Should I use Type=notify instead of simple?
Only if the script calls sd_notify (for example via the sdnotify package) after startup checks pass. It makes systemctl start wait until the watcher is genuinely ready, which helps dependent services.
Can the service run under my own user account?
A user service (systemctl --user) works on desktops, but stops when you log out unless lingering is enabled. On servers, a dedicated system user is cleaner and survives staff changes.
Where do the logs go if the disk fills?
journald rotates by size (SystemMaxUse in journald.conf). Keep application data out of logs and ship important events to your monitoring system rather than relying on local retention.
What about Windows servers? Use a Windows service wrapper such as NSSM, or run the watcher from Task Scheduler at startup, as described in run Python scripts with Windows Task Scheduler.
Related
- Watching Folders for Incoming Documents — the watcher this service runs
- Fix Cron Job Not Running Python Script — the same environment pitfalls for scheduled jobs
- Scheduling and Logging Automation Jobs — logging and scheduling patterns shared with the service
- Fix LibreOffice Headless Conversion Timeout — a typical long-running task inside the watcher that needs timeouts