If a daemon on your server is compromised, the damage it can do is decided long before the exploit lands — by how much of the system that process can already touch. Most units shipped by distributions run with the run of the machine: the whole filesystem writable, every capability available, every syscall permitted. systemd can fence each service into a tight sandbox using a handful of directives in its unit file — no changes to the application itself — and you can measure the result with one command: systemd-analyze security.
This is a hands-on walkthrough on Ubuntu 24.04 LTS. We’ll install a real service (nginx), score it, harden it with a drop-in override, confirm it still serves traffic, and re-score it. Every command below was run on a live system and shows its real output.
What you’ll need
- Ubuntu 24.04 LTS (these steps apply to any systemd distro; package names assume Ubuntu/Debian).
sudo/root for installing packages and editing units.systemd-analyze— it ships with systemd, so it’s already installed:
systemd-analyze --version
systemd 255 (255.4-1ubuntu8.16)
We’ll harden nginx as a concrete example. Install it:
sudo apt update
sudo apt install -y nginx
The following NEW packages will be installed:
nginx nginx-common ...
...
Setting up nginx-common (1.24.0-2ubuntu7.13) ...
Setting up nginx (1.24.0-2ubuntu7.13) ...
Confirm it’s installed and running:
nginx -v
systemctl is-active nginx
nginx version: nginx/1.24.0 (Ubuntu)
active
Measure the baseline
Never harden blind. systemd-analyze security inspects a unit’s sandboxing and prints a per-setting table plus an overall exposure level from 0.0 (locked down) to 10.0 (wide open) — lower is better. Score the stock unit:
systemd-analyze security nginx.service
✗ NoNewPrivileges= Service processes may acquire new privileges 0.2
✗ ProtectSystem= Service has full access to the OS file hierarchy 0.2
✗ PrivateTmp= Service has access to other software's temporary files 0.2
✗ ProtectControlGroups= Service may modify the control group file system 0.2
… (84 settings analysed) …
→ Overall exposure level for nginx.service: 9.6 UNSAFE 😨
9.6 — UNSAFE. That’s normal for a stock unit, not an nginx problem. Run the command with no argument to rank every service and find your worst offenders:
systemd-analyze security
UNIT EXPOSURE PREDICATE HAPPY
dbus.service 9.5 UNSAFE 😨
[email protected] 9.6 UNSAFE 😨
nginx.service 9.6 UNSAFE 😨
…
The directives that matter most
The full list of sandboxing directives lives in systemd.exec(5), and the Arch Wiki keeps a practical systemd sandboxing reference; in practice a focused subset gives you most of the protection.
Filesystem isolation - ProtectSystem=strict — mounts the entire filesystem read-only; pair with ReadWritePaths= for the few dirs the service writes to. - ProtectHome=true — makes /home, /root, /run/user appear empty. - PrivateTmp=true — a private /tmp and /var/tmp, isolated from everything else.
Privilege reduction - NoNewPrivileges=true — the process and its children can never gain new privileges (neutralises setuid binaries). - CapabilityBoundingSet= — drop all Linux capabilities, then add back only what’s needed (nginx binds port 80 and switches workers to www-data, so it keeps CAP_NET_BIND_SERVICE, CAP_CHOWN, CAP_SETUID, CAP_SETGID, CAP_DAC_OVERRIDE). - User= / DynamicUser=yes — never run as root when you don’t have to.
That last point — not running as root — is the same instinct behind running your container engine unprivileged, which I walk through here:

Kernel & system surface - ProtectKernelTunables=, ProtectKernelModules=, ProtectKernelLogs= — block writes to /proc/sys, module loading, and the kernel log. - ProtectControlGroups=true, ProtectProc=invisible — read-only cgroups, hide other processes in /proc.
Network & syscalls - RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX — deny raw/packet/netlink sockets. - For network-facing daemons you can even firewall a unit at the kernel level with IPAddressAllow= / IPAddressDeny=. - SystemCallFilter=@system-service — allow systemd’s curated syscall set, block the exotic ones exploits reach for. - SystemCallArchitectures=native — refuse non-native syscall ABIs.
Cheap wins: RestrictNamespaces=, RestrictRealtime=, RestrictSUIDSGID=, LockPersonality= each close a specific door.
Apply the hardening with a drop-in
Never edit the vendor unit directly — your changes vanish on the next package update. systemctl edit creates a drop-in override under /etc/systemd/system/nginx.service.d/:
sudo systemctl edit nginx
In the editor, add a block that’s known to work for nginx (it keeps the capabilities and write paths nginx actually needs):
[Service]
# Filesystem
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/log/nginx /var/lib/nginx /run
# Privilege
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN CAP_DAC_OVERRIDE CAP_SETGID CAP_SETUID
AmbientCapabilities=CAP_NET_BIND_SERVICE
# Kernel + system
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectProc=invisible
# Network
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Syscalls
SystemCallFilter=@system-service
SystemCallArchitectures=native
# Cheap wins
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
Reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart nginx
systemctl is-active nginx
active
Confirm the override is actually attached:
systemctl cat nginx
# /usr/lib/systemd/system/nginx.service
[Unit]
Description=A high performance web server and a reverse proxy server
...
# /etc/systemd/system/nginx.service.d/hardening.conf
[Service]
ProtectSystem=strict
...
Most importantly — does it still serve traffic? Hardening that breaks the service is worthless:
curl -I http://localhost/
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html
Content-Length: 615
Re-measure the exposure
Score it again:
systemd-analyze security nginx.service
✓ NoNewPrivileges= Service processes cannot acquire new privileges
✓ ProtectSystem= Service has strict read-only access to the OS file hierarchy
✓ PrivateTmp= Service has no access to other software's temporary files
✓ ProtectKernelModules= Service cannot load or read kernel modules
…
→ Overall exposure level for nginx.service: 3.1 OK 🙂
9.6 UNSAFE → 3.1 OK, with nginx still answering on port 80 — the result of one drop-in file, no changes to nginx itself.
Don’t break the service
Sandboxing fails closed: forbid something the daemon actually needs and it stops working. So harden in stages, not in one big drop-in you can’t reason about.
After each change, check the unit is healthy and read the log:
journalctl -u nginx -n 5 --no-pager
systemd[1]: Stopping nginx.service...
systemd[1]: nginx.service: Deactivated successfully.
systemd[1]: Stopped nginx.service.
systemd[1]: Starting nginx.service...
systemd[1]: Started nginx.service.
Common breakers and the fix: - Read-only file system → a missing ReadWritePaths= (or use StateDirectory=). - Service dies after PrivateNetwork=true → it needs the network; remove that line. - Operation not permitted from a syscall → SystemCallFilter= is too tight; temporarily add SystemCallLog=@… to see which call is blocked, then widen it. - Capability errors → add the specific CAP_* back to CapabilityBoundingSet=.
The same confine-what-it-can-reach idea applies to interactive users, not just daemons — here’s how I lock an SFTP account to its own directory:

The takeaway
You don’t need to patch or rewrite a daemon to shrink what it can do when it gets popped. A short drop-in — read-only filesystem, no new privileges, a minimal capability set, a syscall allowlist — took nginx from 9.6 UNSAFE to 3.1 OK while it kept serving traffic. Run systemd-analyze security against your worst-scoring units this week and start there.
