systemd Sandboxing for a Home Server — Giving Every Service a Smaller Blast Radius
A home server rarely stays simple for long. It starts with one web server, then a database joins, then a tunnel, then a monitoring agent, and before you know it a single machine runs a dozen services around the clock. On a typical Linux setup, every one of those services lives in a systemd unit file — and a plain, unmodified unit gives its process a very generous view of the host: the whole filesystem, most capabilities, access to other users' process information. That raises a question worth asking honestly: if just one of these services turns out to have a vulnerability, how far could an attacker get from inside it?
systemd has shipped a large set of built-in sandboxing options for years, and they require no additional software. This article walks through how they work, how to measure where your own services stand, and where the honest limits of this approach are.
What systemd sandboxing is — and what it is not
The options live in the unit file itself and are documented in systemd.exec(5): directives like ProtectSystem=, NoNewPrivileges=, or SystemCallFilter=. Under the hood they combine kernel primitives — mount namespaces, seccomp filters, capability sets, procfs mount options — but from an administrator's point of view they are just lines in a configuration file. Many of them have existed for a long time; ProtectSystem= was added back in version 214, according to the documentation.
Two things this is not: it is not a container runtime and not a virtual machine. The goal is narrower. A container isolates an application's whole environment; sandboxing directives shave away everything one specific service does not need while it keeps running like any other unit. In that sense it is best understood as defense in depth — one more layer between a compromised process and the rest of the machine.
Start by measuring, not guessing
The quickest reality check is systemd-analyze security. Run it without arguments and it reviews every service, assigning each an "exposure level" between 0.0 and 10.0. The documentation is refreshingly candid about what that number means: it is an estimation, high values do not mean a service is actually vulnerable, and they only reflect the sandboxing features systemd itself knows about.
I ran it on the home server that hosts this site, which runs Debian 13 with systemd 257 (the version documented in Debian's man pages). Most services came out between 9.0 and 9.6, labeled UNSAFE: the Cloudflare tunnels, code-server, cron, dnsmasq. The nginx unit still starts its main process as root, with an empty NoNewPrivileges= setting and nearly every capability intact.
There was one interesting exception: MariaDB, installed from Debian's repository, already ships with User=mysql and ProtectSystem=full set by the package maintainers — though notably without NoNewPrivileges=yes. So some pre-hardening exists in distro packages, but it varies from package to package, and nothing stops you from tightening it further with your own overrides.
The option families worth knowing
Dozens of directives exist, but they cluster into three intuitive families.
The filesystem view
ProtectSystem=true mounts /usr and the boot directories read-only; full adds /etc; strict makes the entire hierarchy read-only except for the API filesystems. The documentation recommends enabling it "for all long-running services". ProtectHome=yes hides /home, /root, and /run/user from the service entirely. PrivateTmp=yes gives the process its own private /tmp and /var/tmp.
A read-only world raises an obvious question: where may the service write? Two clean answers exist. ReadWritePaths=/path pokes specific holes into a strict setup, and the directory options — StateDirectory=notes, RuntimeDirectory=notes — create dedicated writable directories under /var/lib or /run, managed by systemd and protected against UID-reuse issues.
Privileges
User=/Group= run the process as a normal account; DynamicUser=yes goes further and allocates a throwaway UID for the lifetime of the service, implying ProtectSystem=strict along the way. On top sits NoNewPrivileges=yes, which the documentation calls "the simplest and most effective way" to ensure a process and its children can never gain privileges through setuid binaries again.
CapabilityBoundingSet= trims the kernel capabilities a process holds — set it to an empty value to drop them all. When a service genuinely needs one, the Arch Wiki documents the common pattern: grant exactly that capability, for example AmbientCapabilities=CAP_NET_BIND_SERVICE so a non-root process can bind port 80 or 443, while CapabilityBoundingSet=CAP_NET_BIND_SERVICE guarantees nothing beyond it. RestrictSUIDSGID=yes forbids creating new setuid files, and ProtectProc=invisible hides other users' processes from /proc.
The kernel and syscall surface
This family cuts the path from a compromised process toward the kernel itself: ProtectKernelModules=yes blocks loading kernel modules, ProtectKernelLogs=yes closes the kernel log ring buffer, ProtectClock=yes denies changing the clock, PrivateDevices=yes removes physical devices from /dev, and RestrictNamespaces=true takes away namespace creation. RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 bans exotic socket families such as packet sockets, and SystemCallArchitectures=native blocks foreign-ABI calls.
The most surgical tool here is SystemCallFilter=, an allow-list of syscalls or predefined syscall groups. @system-service is a curated group covering what typical network daemons need. One directive deserves a warning label: MemoryDenyWriteExecute=yes prohibits writable-plus-executable memory, hardening against a whole exploit class, but the documentation explicitly notes it is incompatible with programs that generate code at runtime — JIT engines included. Node.js and Java services will likely refuse to run with it enabled.
A worked example, measured offline
To see what this concretely changes, I wrote a deliberately plain unit for a small demo service and then a hardened variant:
[Service]
Type=simple
ExecStart=/usr/bin/python3 -m http.server 8080
Restart=on-failure
[Service]
Type=simple
DynamicUser=yes
StateDirectory=notes
RuntimeDirectory=notes
ExecStart=/usr/bin/python3 -m http.server 8080
Restart=on-failure
# Filesystem view
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Privileges
NoNewPrivileges=true
CapabilityBoundingSet=
PrivateDevices=true
ProtectProc=invisible
RestrictSUIDSGID=true
# Kernel surface
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
LockPersonality=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallArchitectures=native
systemd-analyze security has an offline mode that analyzes a unit file directly from disk without installing it, which makes experiments like this safe. On this machine, running systemd-analyze security --offline=true scored the plain unit 9.4 (UNSAFE) and the hardened one 1.6 (OK) — with no unknown-directive warnings, meaning every line parses cleanly on systemd 257. That number is an estimate rather than proof of security, and the offline test obviously cannot tell us whether a real application would tolerate these restrictions; it demonstrates how much attack surface the default leaves on the table.
What tends to break
Sandboxing fails in predictable ways, which is good news — predictability makes debugging tractable.
- JIT runtimes and
MemoryDenyWriteExecute=disagree fundamentally, as described above. Leave it off for V8-based or JVM services. - Shared temp files: once
PrivateTmp=yesis set, two services can no longer exchange files through/tmp— the documentation states this plainly. - Low ports: a service moved from root to a dynamic user loses the ability to bind ports below 1024 unless you hand it
CAP_NET_BIND_SERVICEviaAmbientCapabilities=. - Networked services and namespaces: the Arch Wiki points out that a web server should not use
PrivateNetwork=yes, since it usually needs actual network access. - Vague errors: when a hardened service dies mysteriously,
journalctl -u NAME.service -eis the first stop, and the Arch Wiki suggests raising the manager's verbosity withsystemctl log-level debugif messages stay unclear.
The practical rhythm that follows from this: tighten one family of options, restart, watch the journal, repeat. Not all forty directives at once.
Limits to keep in mind
The biggest risk with a scoring tool is treating the score as a promise. The systemd documentation warns that individual settings "can be circumvented — unless combined with others", and that the analysis ignores anything the service does to protect itself, as well as privileged side channels: a process that can talk over D-Bus may ask better-protected services to do things on its behalf. The Arch Wiki puts it even more bluntly: the score is "slightly misleading", because only a hello-world program can come close to a perfect rating — real applications always need exceptions.
There is also a fair counter-argument from the container world: if every service already runs in Docker or podman with its own isolation, unit-level sandboxing adds less. That reasoning applies to some setups — but plenty of home-server workloads, including several on the machine this site runs on, still ship as plain system units, and those benefit the most.
And sandboxing does not replace the fundamentals. Patching, backups, minimal software, and sane authentication remain the load-bearing walls; sandboxing limits how far a breach spreads after something else has already gone wrong.
Where this leaves me
What I find appealing about this approach is the cost-benefit ratio: the enforcement mechanism has been sitting in every modern distribution for years, waiting behind a few lines of config. My own measurement showed almost everything on this server still running wide open, so the honest conclusion is that this is unfinished work here, not a finished success story. The sensible next step is drop-in overrides — systemctl edit nginx.service creates an overlay instead of touching the vendor file — applied service by service, starting with the ones exposed to the internet. Whether a given service tolerates its sandbox is ultimately an empirical question, and the only reliable way to answer it is to try, watch the journal, and adjust.
References
- systemd project — systemd.exec(5), freedesktop.org (accessed 2026-08-22)
- systemd project — systemd-analyze(1), freedesktop.org (accessed 2026-08-22)
- Debian — systemd.exec(5), Debian trixie manpages (systemd 257) (accessed 2026-08-22)
- Arch Linux Wiki — Systemd/Sandboxing (accessed 2026-08-22)
