Linux Administration·7 min read·

Hardening Services with systemd Sandboxing: ProtectSystem, PrivateTmp, and More

Use ProtectSystem, PrivateTmp, NoNewPrivileges, and friends to sandbox any service, then verify the restrictions with systemd-analyze security.

NB

Netbay Engineering

Netbay Engineering

On this page

Any service that talks to the network will eventually be probed by something hostile, so the working assumption should be that a compromised service can touch everything its user can touch. systemd ships a sandboxing toolkit that shrinks that blast radius with a dozen config lines and zero extra software: no containers to run, no images to build. This post covers the directives with the best protection-to-effort ratio and shows how to prove they are actually working.

Lock Down the Filesystem First

The biggest win comes from removing write access the service never needed. ProtectSystem=strict mounts the whole file hierarchy read-only; ReadWritePaths= then carves out exactly the directories the service must write to. ProtectHome= hides home directories, and PrivateTmp= gives the process its own private /tmp so temp-file collisions and file-based attacks stop being cross-service problems.

ini
[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/invoiced
ProtectHome=true
PrivateTmp=true

Drop Privileges and Escalation Paths

If the unit does not set User=, the service runs as root, and every other hardening line is decoration. Beyond User= and Group=, three directives close the classic escalation routes.

ini
[Service]
User=invoiced
NoNewPrivileges=true
CapabilityBoundingSet=

NoNewPrivileges=true makes it impossible for the service or its children to gain privileges through setuid binaries. CapabilityBoundingSet= with an empty value drops every Linux capability. If the service genuinely needs one — binding port 80 as an unprivileged user, for example — grant exactly that one with AmbientCapabilities=CAP_NET_BIND_SERVICE instead of leaving the default set intact.

Isolate Kernel, Network, and Devices

The next tier hides parts of the system the process should never see: ProtectKernelTunables=true makes /proc/sys read-only, ProtectKernelModules=true blocks loading kernel modules, and DevicePolicy=closed denies access to device nodes except the ones you list. On the network side, RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 prevents exotic socket families, IPAddressDeny=any combined with IPAddressAllow=localhost gives a deny-by-default network policy, and SystemCallFilter=@system-service restricts the syscall surface to a vetted allowlist.

A few more are worth knowing for specific cases: PrivateDevices=true reduces the device view to a minimal pseudo-set, ProtectProc=invisible hides other processes from /proc, InaccessiblePaths= makes chosen paths vanish entirely, and ProtectClock=, ProtectHostname=, and ProtectLogs= each lock down one more kernel interface. Every one of them is a single line, and every one removes an entire class of escape hatch.

None of this requires application changes. It is all enforced by kernel namespaces that systemd sets up around the process before ExecStart runs.

The sandbox is a set of nested boundaries kernel namespaces + cgroups filesystem read-only: ProtectSystem=strict, ProtectHome=true service process NoNewPrivileges=true, no capabilities ReadWritePaths=/var/lib/invoiced only PrivateTmp=true DevicePolicy=closed SystemCallFilter= IPAddressDeny=any ProtectKernelTunables=

Prove It With systemd-analyze security

Hardening you have not measured is hardening you do not trust. systemd-analyze security scores every unit from 0.0 (fully locked down) to 10.0 (no restrictions at all) and lists each directive it checked.

bash
systemd-analyze security invoiced.service
systemd-analyze security | grep -E "UNSAFE|EXPOSED"
systemctl show invoiced -p PrivateTmp -p ProtectSystem

The output names every weak spot with the exact directive to set, which turns hardening from an art form into a checklist. Run it across all your units and fix the worst scores first; anything scoring around 4.0 or below is genuinely contained, while units near 9.0 are running with default exposure.

Roll Out Without Breaking Production

Sandboxes fail in one specific way: the service starts hitting permission-denied errors on paths or syscalls it still legitimately needs. So stage the rollout. Enable filesystem protection first, watch the journal for denials, add a ReadWritePaths= entry for anything blocked incorrectly, then move on to privilege and kernel directives. Two helpers remove most state-directory friction:

ini
[Service]
StateDirectory=invoiced
RuntimeDirectory=invoiced

These create /var/lib/invoiced and /run/invoiced owned by User= automatically and are pre-approved even under ProtectSystem=strict, which eliminates the most common first-day breakage.

Treat the security score like test coverage: the goal is not 0.0 at any cost, it is knowing exactly which capabilities remain granted and being able to defend each one on purpose.

Takeaway: set User=, ProtectSystem=strict with a tight ReadWritePaths=, PrivateTmp=, and NoNewPrivileges= today; measure with systemd-analyze security; deepen from there. Ten lines, dramatically smaller blast radius.

Try it on a disposable box: launch a Linux VPS on Netbay in under 60 seconds and harden something at netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance