Sandboxing systemd Services to Cut Attack Surface
Enumerate, disable, score and sandbox systemd units with drop-ins to shrink the attack surface of every Linux service you run.
Every enabled service is a piece of attack surface: a process that can be reached, exploited, or abused for privilege escalation. systemd gives you two powerful levers to reduce that surface without touching application code — turning off what you do not need, and confining what remains with kernel-enforced sandboxing. This guide walks through enumerating units, scoring their exposure, and applying drop-in hardening that survives upgrades.
Enumerate and reduce what runs
Before hardening anything, find out what is actually enabled and running. A default install ships dozens of units you will never use.
# Units configured to start at boot
systemctl list-unit-files --state=enabled
# Services currently active in this boot
systemctl list-units --type=service --state=running
For each service ask: does this host genuinely need it? A web server rarely needs Bluetooth, ModemManager, or Avahi. Disable what you do not need so it never starts again, and mask things you want to be impossible to start (even as a dependency):
# Stop now and prevent future starts
sudo systemctl disable --now bluetooth.service avahi-daemon.service
# Mask: link to /dev/null so it cannot be started at all
sudo systemctl mask cups.service
Masking is the strongest off switch — a masked unit cannot be activated manually or pulled in by another unit. Reserve it for services you are certain must never run on this host.
Score existing exposure
systemd ships a built-in analyzer that rates how exposed a unit is. Use it as your baseline and your scoreboard.
# Overall ranking of every service by exposure
systemd-analyze security
# Detailed breakdown for one unit
systemd-analyze security nginx.service
The per-unit output lists each sandboxing predicate, whether it is satisfied, and an overall exposure level from OK (well confined) through MEDIUM up to UNSAFE. The detailed table tells you exactly which directives are missing, which is your hardening to-do list.
Sandbox a unit with a drop-in
Never edit vendor unit files in /usr/lib/systemd/system; they get overwritten on upgrade. Instead create a drop-in:
sudo systemctl edit nginx.service
This opens an override under /etc/systemd/system/nginx.service.d/override.conf. Add a [Service] block:
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6
CapabilityBoundingSet=
AmbientCapabilities=
SystemCallFilter=@system-service
MemoryDenyWriteExecute=yes
LockPersonality=yes
RestrictNamespaces=yes
ReadWritePaths=/var/lib/nginx /var/log/nginx
What these do, briefly:
NoNewPrivilegesblocks setuid/setgid escalation for the process and all its children.ProtectSystem=strictmounts the entire filesystem read-only;ReadWritePathscarves out the few directories the service legitimately writes to.ProtectHome=yeshides/home,/root, and/run/userentirely.PrivateTmpandPrivateDevicesgive the service an isolated/tmpand a minimal/dev.ProtectKernelTunables,ProtectKernelModules,ProtectControlGroupsdeny writes to/proc//sys, module loading, and cgroup escapes — closely related to broader kernel hardening.RestrictAddressFamilieslimits sockets to IPv4/IPv6, complementing your network firewall policy.- Emptying
CapabilityBoundingSetandAmbientCapabilitiesdrops every Linux capability; add back only what the daemon needs (for exampleCAP_NET_BIND_SERVICE). SystemCallFilter=@system-serviceallows the standard service syscall set and blocks the rest.
For a defense-in-depth view, pair these confinements with a mandatory access control profile so a single misconfiguration is not the only thing standing between an attacker and the host.
Apply and re-check
Reload the manager, restart the service, and re-score it:
sudo systemctl daemon-reload
sudo systemctl restart nginx.service
systemd-analyze security nginx.service
The exposure level should drop one or more bands. Over-tight sandboxing is the main failure mode — a denied syscall or a missing ReadWritePaths entry will make the service crash on start. Always confirm it is healthy and read the journal:
systemctl status nginx.service
journalctl -u nginx.service -b --no-pager | tail -n 40
Look for Operation not permitted, Read-only file system, or seccomp kill messages — each points to a directive that is too strict for this workload. Loosen the single offending setting rather than abandoning the whole profile. Feed these events into your logging and auditing pipeline so future regressions surface automatically. Iterate one unit at a time until your most exposed daemons are confined.