Hardening systemd Services: Sandboxing Linux Daemons

Hardening systemd Services: Sandboxing Linux Daemons

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:

Step-by-Step Docker Rootless Setup for Enhanced Container Security
Get started with an easy and safe Docker Rootless setup using our complete installation guide. Enhance your container security with this secure method.

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 syscallSystemCallFilter= 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:

Step-by-Step Guide: Setting Up SFTP Jail on Linux
Learn how to securely set up SFTP jail on Linux with our step-by-step guide. Enhance file transfer security and restrict user access effectively.

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.