systemd service hardening: sandboxing what you already run
How to lock down a systemd service with the sandboxing directives that ship in the box, how to measure the result, and where it sits next to a firewall and a hardened SSH setup.
Why This Exists
A firewall decides who reaches the service. SSH hardening decides who reaches the host. Neither says anything about what a service can do to the rest of the machine once it is running and, one day, compromised.
That gap matters because most of what you run does not need most of what it can touch. A metrics exporter has no business reading /home. A web app does not need to load kernel modules. A media server never writes to /etc. By default every one of them can, because a plain unit runs with the full ambient authority of its user.
systemd ships a long list of directives that take that authority away one piece at a time. They cost nothing to run, they need no extra package, and most services never notice. This is the cheapest hardening on the list and the one people skip.
How It Actually Works
The kernel already has the primitives: namespaces, capabilities, seccomp, mount restrictions. Wiring them up by hand is miserable. systemd exposes them as unit options, applies them before your process starts, and gives you one command to score the result.
Three ideas cover most of it:
- Filesystem isolation. Mount the host read-only for the service, hide directories it should never see, and hand back only the paths it genuinely writes to.
- Privilege reduction. Drop Linux capabilities to the empty set, block privilege escalation, and refuse write-and-execute memory.
- Kernel surface reduction. Filter syscalls down to the ones a normal service uses, and block the address families and namespaces it will never open.
You do not need to understand each kernel feature to benefit. You need to know which directive maps to which “this service should never do that.”
Minimum Useful Config
Do not edit the packaged unit. Use a drop-in so package updates leave your changes alone:
1
sudo systemctl edit myservice
That opens an override at /etc/systemd/system/myservice.service.d/override.conf. A sensible starting set:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[Service]
# Refuse setuid/setgid escalation for the process and everything it spawns
NoNewPrivileges=true
# Read-only view of the whole OS, private /tmp, home directories hidden
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Hand back only what the service actually writes to
ReadWritePaths=/var/lib/myservice
# Kernel is off-limits
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
# Drop every capability, then add back only what is needed (usually nothing)
CapabilityBoundingSet=
AmbientCapabilities=
# Syscall all/deny list: allow the normal service set, drop the rest
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Network families most services never need
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
MemoryDenyWriteExecute=true
Reload and restart:
1
2
sudo systemctl daemon-reload
sudo systemctl restart myservice
ProtectSystem=strict is the one that does the heavy lifting and the one most likely to break something, because a service that quietly writes outside its data directory will now fail. That failure is the point: it tells you exactly which path to add to ReadWritePaths, and nothing else stays writable.
Watching It Work
systemd scores a unit’s exposure for you:
1
systemd-analyze security myservice
You get a per-directive table and an overall number from 0 (locked down) to 10 (wide open). A stock third-party unit usually lands around 9. The config above typically pulls the same service down under 3.
1
→ Overall exposure level for myservice.service: 2.4 OK 🙂
The number is a guide, not a grade. A high score on a service that only talks to localhost matters less than a low score on something exposed to the internet. Read the table, not just the total, and spend your effort where the reachable attack surface actually is.
To confirm a restriction is live rather than merely written down, prove it from inside the service’s own context:
1
sudo systemd-run --property=ProtectHome=true --pty ls /home
An empty listing is the sandbox doing its job.
Gotchas Worth Knowing
ProtectSystem=strict breaks sloppy services on purpose. If a unit fails to start after you apply it, read journalctl -u myservice for the read-only-filesystem error, add that path to ReadWritePaths, and try again. Do not reach for the weaker ProtectSystem=full unless you have a real reason; you are trading away most of the benefit.
SystemCallFilter can kill a service silently. Some runtimes need syscall groups outside @system-service. If a process dies on start with SIGSYS, that is the filter. Add the missing group (for example @resources) rather than removing the filter, and never allow @obsolete or @raw-io without a specific need.
Containers already do some of this. If a service runs as a rootless Podman or Quadlet unit, it inherits its own namespaces and a default seccomp profile, so a few directives are redundant. The filesystem and capability options still apply to the container runtime itself and are worth keeping.
Test the actual workload, not just startup. A service can start clean and then fail the first time it opens a socket, forks a helper, or writes a cache file. Exercise the real paths after hardening, watch the journal, and only then walk away.
Where It Fits
The layers, from the outside in:
- Firewall: default-deny inbound, only the ports you serve.
- SSH hardening: keys only, no root login, no password auth.
- fail2ban: ban the brute-force noise that gets past the first two.
- Service sandboxing: assume a service will eventually be compromised, and make sure it lands in an empty room.
The first three keep attackers out. This one decides how much they get when one of them is wrong, which eventually one of them is. It is the difference between a compromised exporter reading a metric and a compromised exporter reading your home directory. The directives are already installed. The only cost is the ten minutes it takes to write the drop-in and read the journal once.