Sandboxing Linux services with systemd security directives
Turn a plain unit file into a locked-down sandbox with ProtectSystem, NoNewPrivileges, and capability limits.
You do not always need a container to shrink attack surface. systemd can sandbox long-running services using directives in the unit file — read-only filesystem views, dropped capabilities, private /tmp, and syscall allowlists — with no application code changes. Each directive removes a privilege the process almost certainly does not need. systemd-analyze security scores how far you have locked a unit down and points at the next knob to turn.
This note hardens a typical API service unit, explains what each directive buys you, and shows how to iterate without breaking production on the first deploy. For broader Linux hardening context — capabilities, namespaces, audit — see Linux hardening and Linux essentials if unit files are new territory.
Add directives in layers. Reload, restart, exercise the service, check journal for EPERM. Roll back one directive at a time if something breaks.
A hardened unit file
ReadWritePaths is the escape hatch — list only directories the service must mutate. Everything else under / becomes read-only or invisible. DynamicUser=yes creates an ephemeral uid/gid pair so you do not manage service accounts by hand.
Drop-in directives like ProtectKernelTunables=yes and ProtectKernelModules=yes block many container-escape primitives on bare metal too. RestrictRealtime=yes stops SCHED_FIFO abuse. Combine with AmbientCapabilities= empty and PrivateUsers=yes only after testing — some daemons expect fixed uids in config files.
[Service]ExecStart=/usr/local/bin/apiDynamicUser=yesNoNewPrivileges=yesProtectSystem=strictProtectHome=yesPrivateTmp=yesPrivateDevices=yesReadWritePaths=/var/lib/apiCapabilityBoundingSet=RestrictAddressFamilies=AF_INET AF_INET6SystemCallFilter=@system-serviceSystemCallErrorNumber=EPERM
Measure with systemd-analyze
Run systemd-analyze security api.service before and after each change. The tool suggests EXPOSED vs SAFE for each setting. Aim for incremental improvement — a score jump from 3.2 to 7.8 is meaningful even if you never hit a perfect 10 on legacy binaries that expect broad filesystem access.
Ship hardened unit files through configuration management — Ansible, cloud-init, or golden AMIs — so manual edits on one host do not drift. Version the unit in git and reload with systemctl daemon-reload in your deploy pipeline. Document which directives were tried and reverted so the next engineer does not repeat a broken SystemCallFilter.
systemd-analyze security api.serviceProtectSystem=strict (SAFE)CapabilityBoundingSet= (SAFE)IPAddressDeny= (EXPOSED)systemctl daemon-reload && systemctl restart apijournalctl -u api -n 20 --no-pagerService started without EPERM errorsWhere this goes next
Combine systemd sandboxing with Linux capabilities tuning for containers and bare metal, forward logs via journald to a central host, and watch syscalls with audit rules on sensitive binaries. Linux hardening covers SSH, firewall, and AppArmor/SELinux layers that complement unit-file sandboxing.
Not every service belongs in a maximum sandbox — databases need predictable uid files and wide ReadWritePaths. Tune per unit, store the final file in git, and re-run systemd-analyze security after every package upgrade in case the vendor changed paths.