Harden a systemd Service: ProtectSystem and NoNewPrivileges

Run systemd-analyze security against two ordinary services on the same host and the scores can land almost six points apart — one near UNSAFE, the other down close to OK — without either service being broken or badly written. On a stock Ubuntu box running systemd 259, cron.service measures 9.6 UNSAFE while chrony.service measures 3.7 OK, per Bulwark's directive-by-directive sandboxing guide. The gap isn't about code quality — cron genuinely needs broad access to run arbitrary jobs as arbitrary users, while Debian and Ubuntu ship chrony's unit file with a static non-root user, ProtectSystem=strict, and most of its Linux capabilities already stripped. Every systemd service inherits whatever the handful of directives in its [Service] block grant it, and by default that's close to everything the host can do. This guide works through ten of those directives that do most of the real work, the supporting ones worth adding alongside them, and systemd-analyze security as the way to confirm any of it actually moved the needle.

Quick Verdict
systemd-analyze security <unit>.service scores a unit from 0.0 (tightest) to 10.0 (almost no sandboxing), in named bands from PERFECT down to DANGEROUS, with OK spanning 1.0–5.0 and UNSAFE starting at 9.0. Ten directives do most of the real work — ProtectSystem, ProtectHome, PrivateTmp, NoNewPrivileges, CapabilityBoundingSet, PrivateDevices, ProtectKernelModules, RestrictNamespaces, LockPersonality, and MemoryDenyWriteExecute — each closing off one specific piece of the host rather than all of it at once. Add directives through systemctl edit <unit>, never by hand-editing the packaged unit file, and add them a few at a time: one measured build took a test service from 9.6 UNSAFE to 4.9 with sixteen directives, and a separate build moved a self-hosted service from 7.6 down to 1.1 the same incremental way.

Score It First: What systemd-analyze security Measures

systemd-analyze security <unit>.service "scores a unit's actual configuration against every sandboxing directive systemd knows about," per Bulwark, which quotes systemd-analyze(1)'s own description of the result as "an estimation in the range 0.0…10.0 indicating how exposed a service is security-wise" — lower is better. The scores sort into seven named bands — PERFECT, SAFE, OK, MEDIUM, EXPOSED, UNSAFE, DANGEROUS — with OK spanning 1.0–5.0 and UNSAFE starting at 9.0, per the Bulwark guide. Worth reading carefully before treating any single number as a verdict: the tool itself states that a high exposure level "neither means that there is no effective sandboxing applied by the service code itself, nor that the service is actually vulnerable to remote or local attacks." It measures how much of the host a compromised process could reach, not whether the service is currently compromised.

The NixOS wiki's hardening page frames the same command as the right starting point for any hardening pass: run systemd-analyze security myService, then read man systemd.exec or man systemd.resource-control for whatever options the report flags, and set the ones that apply. A separate, practical detail from the same page: since systemd v258, systemd-analyze unit-shell <service> opens a shell inside a running hardened unit — useful for checking what a sandboxed service can actually see on disk or on the network without having to loosen the hardening just to debug it.

Filesystem Confinement: ProtectSystem, ProtectHome, and PrivateTmp

ProtectSystem has three settings. true makes /usr and /boot read-only; full adds /etc; strict makes the entire filesystem hierarchy read-only except the live /dev, /proc, and /sys trees — the level the dev-toolbox.tech hardening reference and OneUptime's walkthrough both call the recommended default. Bulwark adds a caution worth taking seriously: strict is routinely oversold as "the process can only write to ReadWritePaths=." In fact those three API trees stay writable on their own — closing them takes PrivateDevices=, ProtectKernelTunables=, and ProtectControlGroups= — and so do /tmp and /var/tmp if PrivateTmp= is on, plus anything granted through StateDirectory=, LogsDirectory=, CacheDirectory=, or RuntimeDirectory=. It's a large reduction in writable surface, not an airtight one.

ProtectHome= is also broader than its name implies: it governs /home, /root, and /run/user together. The value read-only makes all three read-only; yes makes them "inaccessible and empty" to the service; tmpfs mounts an empty, read-only tmpfs over them instead. PrivateTmp=yes gives the unit its own private /tmp and /var/tmp, unreachable by and unable to reach other processes' temp files — closing off a whole class of /tmp-based interference between unrelated services, per the Bulwark guide. For services that can tolerate it, the NixOS wiki's worked example goes further still with RootDirectory= and a pivot_root-style private filesystem root, bind-mounting in only what the service actually needs — the Nix store, a TLS certificate bundle — through BindReadOnlyPaths=.

Privilege Containment: NoNewPrivileges and CapabilityBoundingSet

NoNewPrivileges=yes is, per systemd's own documentation as quoted by Bulwark, "the simplest and most effective way to ensure that a process and its children can never elevate privileges again" — it blocks execve() from granting new privileges through setuid/setgid bits or filesystem capabilities. The dev-toolbox.tech reference calls it flatly "the most important single hardening directive." It has one documented gap worth knowing: the same man-page text notes "it has no effect on processes potentially invoked on request of them through tools such as at(1), crontab(1), systemd-run(1), or arbitrary IPC services" — work handed off that way runs in a different context and escapes the restriction.

CapabilityBoundingSet= strips every Linux capability not explicitly listed — set it to an empty value to remove all of them, or to a short allow-list for a service with a real need. The recurring example across sources is CAP_NET_BIND_SERVICE, which lets a non-root process bind to a port below 1024 (CAP_NET_BIND_SERVICE alone won't do it at runtime without also setting AmbientCapabilities=CAP_NET_BIND_SERVICE to actually grant it, per OneUptime's nginx example). On a real hardening pass documented by a Discord-bot service walkthrough, setting CapabilityBoundingSet= empty was the biggest drop any single directive produced — the exposure score fell from 4.7 OK to 2.8 OK on that one change alone.

One trap is worth checking for by hand, because it's invisible in a quick read and systemd-analyze is exactly the tool that catches it: directives are plain assignments, and the last one wins. Per Bulwark, Debian's own packaged chrony.service sets NoNewPrivileges=yes near the top of the file, then further down — under a comment about sendmail for its mailonchange directive — sets NoNewPrivileges=no. On a stock install, that directive is effectively off, which is part of why chrony scores 3.7 rather than lower despite otherwise being one of the better-hardened default units.

Kernel, Device, and Clock Isolation

PrivateDevices=yes gives the unit a /dev containing "only API pseudo devices such as /dev/null, /dev/zero or /dev/random (as well as the pseudo TTY subsystem)," per Bulwark — no /dev/sda, no raw memory through /dev/mem, no /dev/port. It also strips CAP_MKNOD and CAP_SYS_RAWIO from the capability bounding set and blocks the @raw-io syscall group, so it does more than just reshape a directory listing. ProtectKernelModules=yes and ProtectKernelTunables=yes remove the access a process would need to load a kernel module or alter /proc/sys at runtime. ProtectKernelLogs=yes denies access to the kernel log ring buffer — what dmesg reads. ProtectClock=yes denies writes to the hardware clock or system clock, closing a narrow but real timestamp-tampering vector for a compromised service trying to confuse log correlation.

A French-language companion guide on GitHub groups twelve directives — ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, RestrictSUIDSGID, PrivateTmp, NoNewPrivileges, ProtectHome, LockPersonality, ProtectHostname, ProtectClock, ProtectKernelLogs, and RestrictRealtime — into what it calls a "default safe" set usable on most services without significant risk. In its own worked example, a plain Python http.server unit went from a 9.6 UNSAFE score to 4.9 after those directives (plus a few others) were added.

Namespaces, Syscalls, and Two Directives Worth Reading Twice

RestrictNamespaces=yes prohibits "access to any kind of namespacing" — creating or switching into one — closing off container-escape-adjacent techniques that rely on nesting a new namespace inside an already-compromised process, per Bulwark. LockPersonality=yes locks personality(2) so "the kernel execution domain may not be changed," a rarely-legitimate syscall occasionally used to weaken memory-layout protections.

MemoryDenyWriteExecute=yes blocks a process from mapping memory as both writable and executable at the same time, aimed directly at classic write-then-execute shellcode injection. Two caveats are worth knowing before relying on it. First, it's circumventable: per Bulwark, "the protection can be circumvented, if the service can write to a filesystem, which is not mounted with noexec (such as /dev/shm), or it can use memfd_create()" — closed by adding InaccessiblePaths=/dev/shm and SystemCallFilter=~memfd_create alongside it. Second, it's flatly incompatible with anything that generates code at runtime — JIT engines, executable stacks, the "trampoline" feature some C compilers use — which rules it out for most JVM, .NET, and JavaScript workloads. Set it on services that don't JIT; expect a crash if you set it on one that does.

SystemCallArchitectures=native restricts a unit to native system calls only, rejecting a non-native ABI such as a 32-bit compatibility table on a 64-bit host — a real, if narrow, bypass surface that's off by default. SystemCallFilter=@system-service is systemd's own curated set of calls an ordinary service needs, which the man page calls "the recommended starting point for allow-listing system calls for system services," per Bulwark. It's typically paired with an explicit denial of groups a normal service has no legitimate reason to call — @privileged, @resources, @clock, @mount, @module, @reboot, @swap, @raw-io, @debug, @cpu-emulation, @obsolete — where a leading ~ inverts the list from an allowance into a denial. OneUptime's nginx configuration pairs that filter with SystemCallErrorNumber=EPERM, so a blocked syscall returns a permission error the service can potentially handle gracefully, rather than being killed outright.

Applying the Directives Without Breaking the Service

Add every hardening directive through a drop-in override, never by editing the packaged unit file directly — a distro package update overwrites the original and silently reverts any hand-edited hardening, per OneUptime. sudo systemctl edit <unit>.service opens that drop-in under /etc/systemd/system/<unit>.d/, and per Bulwark, systemd reloads the configuration automatically once the editor exits — "equivalent to systemctl daemon-reload" — but the running process still needs an explicit restart, since a reload rebuilds systemd's view of the unit without re-executing the process already running under the old configuration.

Add directives a few at a time and restart between batches rather than pasting everything in at once — it's far easier to isolate which single directive broke the service than to debug a dozen simultaneously, per Bulwark. The same staged logic shows up in dev-toolbox.tech's own walkthrough: NoNewPrivileges and PrivateTmp first as a safe baseline, then ProtectSystem=strict and ProtectHome with ReadWritePaths= carved out for whatever the service actually writes, then PrivateDevices, ProtectKernelTunables, and MemoryDenyWriteExecute last, since those three are the most likely to need a real test pass against the service's actual behavior. Two measured examples show how much that staged approach moves the number: a Python http.server test unit went from 9.6 UNSAFE to 4.9 after sixteen directives were added — the twelve above plus ProtectSystem=strict, CapabilityBoundingSet, RestrictNamespaces and PrivateDevices — per the samderkaoui guide, and a self-hosted Discord bot's unit dropped in four steps — 7.6 EXPOSED, then 6.7 MEDIUM, then 4.7 OK, then 2.8 OK, then 1.1 OK — as filesystem, namespace, capability and finally system-call restrictions were layered on in turn, per the sergeantbiggs write-up.

If a service fails after a new directive, OneUptime's troubleshooting path is a reasonable default: check journalctl -u <service> --since "5 minutes ago" -p err for the service's own error first, then journalctl -k | grep "audit: type=1326" for a specific blocked syscall, or grep the kernel log for apparmor|denied|audit. Most hardening failures surface as a plain permission-denied error rather than a cryptic crash, which is usually enough to point straight at the one directive that's too strict for what the service actually does.

Who This Is Worth Doing For

Good fit:

  • Any self-hosted service reachable from the open internet, where a hardened unit limits what a compromised process can actually touch
  • Services with a packaged unit file available to override with a drop-in — most distro packages and most natively-run services
  • Admins already comfortable testing with systemctl restart and reading journalctl output after each change

Not the best fit:

  • A service you can't restart to test without real downtime and no staging copy to break safely first
  • Anything built on a JIT runtime — most JVM, .NET, or Node.js processes — where MemoryDenyWriteExecute will crash it outright
  • A unit where the packaged defaults are already close to the ceiling, like chrony's — diminishing returns don't justify the testing time

Ten Directives, One Score to Watch

None of this requires rewriting the service itself — every directive lives in the unit's [Service] block, and systemd-analyze security turns "we hardened it" from a claim into a number that moves when something actually changed. Start with the twelve directives the samderkaoui guide lists as safe for most services — ProtectKernelTunables, ProtectKernelModules, ProtectControlGroups, RestrictSUIDSGID, PrivateTmp, NoNewPrivileges, ProtectHome, LockPersonality, ProtectHostname, ProtectClock, ProtectKernelLogs, and RestrictRealtime — restart, rerun the score, then work through ProtectSystem=strict, CapabilityBoundingSet, and the namespace and syscall directives against whatever the service's real I/O and network needs turn out to be. A service that scores UNSAFE out of the box isn't necessarily broken, and one that scores OK isn't necessarily safe from everything — but the number tells you, directive by directive, exactly how much of the host a compromised process in that unit would actually be able to reach.

References

Posts in this series