# Hardening Linux Services with systemd Sandboxing

> How I lock down Linux daemons from the outside using systemd's built-in sandbox — filesystem, temp, privileges, and syscalls — then measure the result.

URL: https://www.matthewswong.com/en/blog/systemd-hardening-sandboxing-services/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-07-09
- Updated: 2026-07-09
- Category: Security

> **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.

Read 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.

## Lock 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.

The 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.

> A 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.

## Sources

- Red Hat — Mastering systemd: Securing and sandboxing applications and services
- systemd-analyze(1) manual — security verb and exposure score
- systemd.exec(5) manual — sandboxing directives reference

## Source links

- https://www.redhat.com/en/blog/mastering-systemd
- https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html
- https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html

## FAQ

### What does systemd-analyze security actually measure?

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.

### What is the difference between ProtectSystem yes, full, and strict?

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.

### Will SystemCallFilter break my service?

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.

### Should I edit the unit file directly or use a drop-in?

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.

### Is a low systemd-analyze score enough to call a service secure?

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.

---

- HTML version: https://www.matthewswong.com/en/blog/systemd-hardening-sandboxing-services/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/systemd-hardening-sandboxing-services/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
