Key Takeaway
systemd can sandbox a service without touching its code. Set ProtectSystem=strict to make the filesystem read-only, PrivateTmp=yes to isolate temp files, NoNewPrivileges=yes to block privilege escalation, and SystemCallFilter=@system-service to restrict kernel calls. Then run systemd-analyze security to score how exposed the unit still is.
Most of the services running on a Linux box did not need root, did not need write access to the whole filesystem, and did not need three hundred syscalls. They got all of it anyway, because that is the default. A single exploited daemon then owns everything the process could technically reach. On a VPS I run, I treat every long-lived service as a potential foothold, and the cheapest defense I have found is systemd's built-in sandbox.
The nice part is that none of this lives in the application. It lives in the unit file, or better, in a drop-in override. You harden the daemon from the outside, measure the result with a single command, and roll it back the moment something breaks. This post walks through the directives that matter, the order I apply them in, and how to read the score without chasing a perfect number.
Start by measuring, not editing
Before you change anything, get a baseline. systemd-analyze security reports a weighted exposure score from 0.0 to 10.0 for a unit, where high means loose and low means tightly locked down. It then labels the unit OK, MEDIUM, EXPOSED, or UNSAFE. A stock service with no hardening will usually land in EXPOSED or UNSAFE territory, and that is fine — it is your starting line, not a grade.
# Score one unit and see every check, sorted by exposure
systemd-analyze security myapp.service
# The tail of the output looks like this:
# → Overall exposure level for myapp.service: 9.6 UNSAFE 😨
# List every loaded service, worst first
systemd-analyze securityRead the per-line output. Each row shows a setting, whether it is set, and how much it contributes to exposure. That table is your to-do list — the highest-weighted red rows are where you get the most security per line of config.
Never edit a package-shipped unit file in /usr/lib/systemd/system directly — a package update overwrites it. Run the command below to create a drop-in, and your changes live in /etc/systemd/system and survive upgrades.
# Opens (or creates) a drop-in override, no risk to the shipped unit
sudo systemctl edit myapp.service
# It writes to:
# /etc/systemd/system/myapp.service.d/override.confLock the filesystem: ProtectSystem and ProtectHome
ProtectSystem controls how much of the filesystem the service can write to. The value yes makes /usr and /boot read-only; full adds /etc; strict makes the entire filesystem read-only except /dev, /proc, and /sys. I default to strict and then punch specific holes with ReadWritePaths for the handful of directories the service genuinely needs to write. ProtectHome=yes hides /home, /root, and /run/user entirely, which almost no daemon should ever touch.
[Service]
ProtectSystem=strict
ProtectHome=yes
# Whitelist exactly what needs to be writable
ReadWritePaths=/var/lib/myapp /var/log/myapp
# A private, per-service /tmp that no other process can see
PrivateTmp=yesThe failure mode here is predictable: the service starts, tries to write to a path you did not whitelist, and dies with a permission or read-only-filesystem error. That is a feature. journalctl -u myapp will name the exact path, you add it to ReadWritePaths, and you move on. This is why you harden incrementally rather than pasting a giant block and hoping.
The cheap, high-impact wins
A handful of directives cost almost nothing and rarely break anything. I apply these to nearly every service before I even think about syscall filtering:
- NoNewPrivileges=yes — the process and its children can never gain more privileges than they started with. This alone neutralizes most setuid-based escalation and is safe for the overwhelming majority of services.
- PrivateTmp=yes — gives the service its own /tmp and /var/tmp, killing an entire class of predictable-temp-file attacks and symlink races.
- ProtectKernelTunables=yes and ProtectKernelModules=yes — block writes to /proc and /sys tunables and forbid loading kernel modules.
- ProtectControlGroups=yes and RestrictSUIDSGID=yes — make the cgroup hierarchy read-only and forbid creating setuid or setgid files.
- PrivateDevices=yes — replaces /dev with a minimal stub, so a compromised service cannot poke at raw disks or other hardware nodes.
SystemCallFilter — the sharp tool
SystemCallFilter restricts which kernel syscalls the service may make, backed by seccomp. The pragmatic approach is to allow the curated @system-service group, which covers what typical daemons need, and then explicitly deny dangerous groups like @privileged and @mount. This is the single biggest score improvement for most units, and also the most likely to break something subtle, so it goes last.
[Service]
# Allow the baseline set most services need
SystemCallFilter=@system-service
# Then subtract the dangerous ones (the ~ means deny)
SystemCallFilter=~@privileged @mount @reboot @swap
# Return EPERM instead of killing with SIGSYS — easier to debug
SystemCallErrorNumber=EPERM
# Restrict to the address families you actually use
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6A syscall filter that is too tight fails in confusing ways — a service starts fine, then hangs or crashes only when it hits a rare code path hours later. Test under real load, watch the journal for seccomp denials, and keep SystemCallErrorNumber=EPERM during rollout so denials surface as normal errors instead of an instant kill.
Directive cheat sheet
| Directive | What it does | Breakage risk |
|---|---|---|
| ProtectSystem=strict | Whole filesystem read-only except /dev, /proc, /sys | Medium — needs ReadWritePaths tuning |
| PrivateTmp=yes | Isolated per-service temp directories | Low |
| NoNewPrivileges=yes | Blocks all privilege escalation | Low |
| PrivateDevices=yes | Minimal /dev, no raw hardware access | Low to medium |
| SystemCallFilter=@system-service | seccomp allow-list of common syscalls | High — test under real load |
Read the score, don't worship it
After each change, run systemctl daemon-reload, restart the service, confirm it actually works, then re-score it. Watching a unit move from 9.6 UNSAFE to around 2.0 OK is satisfying, but the number is a heuristic, not a proof. It only measures the sandbox systemd itself applies — it knows nothing about your application's own auth, input validation, or the secrets it holds. A daemon that runs as a dedicated non-root user with a read-only filesystem and no dangerous syscalls is dramatically harder to weaponize even if the score never hits zero.
Pair sandboxing with a dedicated system user: set DynamicUser=yes (or a fixed User= and Group=) so the service never runs as root in the first place. Combined with the filesystem and syscall restrictions above, that is the difference between a bug and a breach.
# Full workflow, start to finish
sudo systemctl edit myapp.service # add the [Service] hardening block
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl status myapp.service # confirm it is running
journalctl -u myapp.service -e # check for denials
systemd-analyze security myapp.service # re-score
