Most Linux services run with far more privilege than they need. systemd includes a sandboxing framework that removes most of it declaratively — no container runtime, no separate tooling, just directives in a unit file.

The result is a service that keeps working exactly as before while losing the ability to read /home, load kernel modules, or write anywhere outside its own state directory.

Measure what you have

systemd-analyze security scores every unit from 0 (locked down) to 10 (unconstrained):

Bash
systemd-analyze security
Text
UNIT                          EXPOSURE PREDICATE HAPPY
nginx.service                      9.2 UNSAFE    😨
postgresql.service                 9.0 UNSAFE    😨
ssh.service                        9.6 UNSAFE    😨
systemd-resolved.service           2.2 OK        🙂

systemd-resolved scores 2.2 because upstream hardened it deliberately. Most distribution packages ship unhardened defaults for compatibility.

Drill into one unit to see exactly what is missing:

Bash
systemd-analyze security nginx.service

Never edit the packaged unit file — it gets replaced on upgrade. Use a drop-in:

Bash
systemctl edit nginx.service
# creates /etc/systemd/system/nginx.service.d/override.conf

The high-value directives

Apply these in order. Each is a meaningful reduction on its own.

Filesystem isolation

INI
[Service]
# Private /tmp, invisible to and from every other process.
PrivateTmp=true

# The entire filesystem is read-only except what you explicitly allow.
ProtectSystem=strict
ProtectHome=true

# Writable paths, created with the right ownership automatically.
StateDirectory=myapp
LogsDirectory=myapp
RuntimeDirectory=myapp
ReadWritePaths=/var/lib/myapp /var/log/myapp

ProtectSystem=strict is the strongest of the three settings — /usr, /boot, /etc, and everything else is read-only. Combined with explicit ReadWritePaths, a compromised service cannot modify a binary or drop a persistence mechanism in /etc/cron.d.

The StateDirectory family is better than hand-managing paths: systemd creates the directory under /var/lib, sets ownership to the service user, and cleans up on uninstall.

Privilege restriction

INI
# Cannot gain privileges through setuid binaries or file capabilities.
NoNewPrivileges=true

# Drop every capability, then grant back only what is needed.
CapabilityBoundingSet=
AmbientCapabilities=

# A service binding port 80 needs exactly this and nothing else:
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# AmbientCapabilities=CAP_NET_BIND_SERVICE

User=myapp
Group=myapp
DynamicUser=true

NoNewPrivileges=true is the single most valuable line here. It blocks the entire class of privilege escalation that runs through setuid binaries — a compromised service cannot invoke sudo, pkexec, or any custom setuid helper.

DynamicUser=true allocates a transient UID for the service's lifetime. There is no persistent account to target, and no /etc/passwd entry to hijack. It requires the StateDirectory pattern above, since the UID changes between restarts.

Kernel and namespace protection

INI
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid

RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true

ProtectProc=invisible hides every other process in /proc. A compromised web service can no longer enumerate what else runs on the host, read another process's command line, or find credentials passed as arguments.

MemoryDenyWriteExecute=true blocks memory pages that are both writable and executable, which defeats most shellcode injection. It breaks anything with a JIT — Java, Node.js, PyPy, and some Python C extensions. Test carefully.

Network restriction

INI
# IPv4 and IPv6 only — no raw sockets, no AF_PACKET, no netlink.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# For a service that must not reach the network at all:
# PrivateNetwork=true

# Egress filtering, enforced by the kernel via BPF.
IPAddressDeny=any
IPAddressAllow=localhost
IPAddressAllow=10.0.0.0/8

IPAddressDeny/IPAddressAllow are genuinely underused. They give you per-service egress filtering without touching nftables, which means a compromised application cannot call out to an attacker's infrastructure even though the host has general internet access.

System call filtering

INI
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

@system-service is a curated set covering what normal services need. The ~ prefix subtracts groups: @privileged (admin syscalls), @resources (resource limit manipulation), @obsolete (deprecated interfaces).

SystemCallArchitectures=native blocks the x86 compatibility interface on x86-64 — a well-known route around seccomp filters that only cover 64-bit syscall numbers.

A worked example

Hardening a typical Python web service:

INI
# /etc/systemd/system/myapp.service.d/override.conf
[Service]
# Identity
DynamicUser=true
StateDirectory=myapp
LogsDirectory=myapp
RuntimeDirectory=myapp

# Filesystem
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myapp

# Privileges
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=true

# Kernel
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pid
LockPersonality=true
RestrictRealtime=true
RestrictNamespaces=true

# Network — the app talks to a database and nothing else
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
IPAddressDeny=any
IPAddressAllow=localhost
IPAddressAllow=10.20.0.0/16

# Syscalls
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

# Resource limits — a memory leak should not take the host with it
MemoryMax=2G
TasksMax=256
LimitNOFILE=8192

Note MemoryDenyWriteExecute is absent — CPython's C extensions need it. This is the normal outcome: you apply everything, find the one or two directives that break the workload, and document why they are omitted.

Apply and verify:

Bash
systemctl daemon-reload
systemctl restart myapp.service
systemd-analyze security myapp.service
Text
→ Overall exposure level for myapp.service: 1.8 OK 🙂

From 9.4 to 1.8, with no application changes.

Debugging what breaks

Hardening failures are usually opaque — the service starts and immediately fails with a permission error that names nothing useful.

Check the audit log for seccomp denials:

Bash
journalctl -u myapp.service -p err --since "5 min ago"
ausearch -m SECCOMP -ts recent
Text
type=SECCOMP msg=audit(1753900112.441:892): auid=4294967295 uid=979 gid=979
  ... syscall=157 compat=0 ip=0x7f3a2c8b4b19 code=0x50000

Syscall 157 on x86-64 is prctl. Resolve numbers with:

Bash
ausyscall x86_64 157

Then either add the specific syscall back, or relax the group that contains it:

INI
SystemCallFilter=@system-service @privileged

Bisect rather than guess. Comment out half the hardening directives, restart, and see whether the failure persists. Four or five iterations isolates the culprit far faster than reading syscall tables.

Where to apply this first

Prioritise by exposure:

  1. Internet-facing services — web servers, reverse proxies, mail. Highest probability of being the initial foothold.
  2. Services parsing untrusted input — log processors, image converters, document renderers. Historically the richest source of memory-safety bugs.
  3. Anything running as root that you cannot immediately convert to a service account. Hardening buys you most of the benefit while the proper fix is scheduled.

Custom applications you deploy yourself are the easiest place to start, because you control the unit file and can test freely. Do those first, build confidence in the directives, then work through the packaged services.