Hardening Linux Services with systemd Sandboxing

It reports a weighted exposure score from 0.0 to 10.0 that reflects how much sandboxing a service unit has applied, then labels it OK, MEDIUM, EXPOSED, or UNSAFE. High scores mean loose settings and low scores mean tight restrictions. Crucially, it only evaluates the per-service features systemd itself provides — it cannot see your application's own authentication, input validation, or logic.
yes makes /usr and /boot read-only, full additionally makes /etc read-only, and strict makes the entire filesystem read-only except /dev, /proc, and /sys. strict is the most secure and the one I default to. When a service needs to write somewhere, you whitelist just those paths with ReadWritePaths instead of loosening the whole policy.
It can, which is why you apply it last and test under real load. A too-tight filter often lets a service start normally, then fails hours later on a rare code path. Start with SystemCallFilter=@system-service, set SystemCallErrorNumber=EPERM so denials become ordinary errors instead of instant kills, and watch the journal for seccomp messages before trusting it in production.
Use a drop-in via systemctl edit. Editing a package-shipped unit in /usr/lib/systemd/system means a package update silently overwrites your hardening. A drop-in writes to /etc/systemd/system/<name>.service.d/override.conf, which takes precedence over the shipped file and survives upgrades. It also keeps your changes isolated and easy to remove if something breaks.
No. The score is a heuristic that only covers systemd's own sandbox directives. It says nothing about application-level vulnerabilities, weak secrets, or exposed network endpoints. Treat a good score as one layer of defense in depth — combine it with a dedicated non-root user, least-privilege network exposure, and normal application security practices.

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.
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.confProtectSystem 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.
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:
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 | 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 |
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