Practical systemd sandboxing for small services
Most internal services run as root because nobody wanted to think about it for twenty minutes. Here are the twenty minutes.
Start with a user, not with directives
Every sandboxing directive in the world is less valuable than simply not being root. Create a system user with no shell and no home:
useradd --system --no-create-home \
--shell /usr/sbin/nologin \
--home-dir /nonexistent myservice
The high-value directives
These four give you most of the benefit and rarely break anything:
| Directive | What it stops |
|---|---|
NoNewPrivileges=true | setuid escalation from inside the process |
ProtectSystem=strict | writes anywhere outside /dev, /proc, /sys |
ProtectHome=true | reading /home, /root, /run/user |
PrivateTmp=true | tmp file races with other services |
ProtectSystem=strict is the one people bounce off, because the service
immediately fails to write its own logs or state. The answer is not to weaken it,
it is to declare exactly what should be writable:
ProtectSystem=strict
ReadWritePaths=/var/log/myservice /var/lib/myservice
The one that will waste your afternoon
SystemCallFilter=@system-service is good hygiene, but pair it with
SystemCallErrorNumber=EPERM and every blocked syscall comes back as a
plain permission error. You will spend an hour convinced you have a file ownership
problem. If a service dies with a confusing permission denied, comment
out the syscall filter first and see if it goes away.
Related trap: if you ever run the binary manually as root to test the config, it may create its log files owned by root. The service then cannot open them as its own user. Same symptom, completely different cause.
Checking your work
systemd-analyze security myservice.service scores the unit and lists
what is still open. Do not chase a perfect score. Getting from 9.5 (unsafe) to
around 3 covers the realistic threats for an internal daemon.
systemd-analyze security myservice.service
You can also dry-run a directive without touching the unit file at all, which is the fastest way to bisect a broken sandbox:
systemd-run --uid=myservice -p ProtectSystem=strict \
-p ReadWritePaths=/var/log/myservice \
--pipe --wait /bin/sh -c 'touch /var/log/myservice/x'