← blog · October 10, 2026

Hardening Services with systemd: ProtectSystem, DynamicUser, System Call Filters and the Options That Break Things

Shrinking a service's attack surface from the unit file without reaching for containers: a starting template, measuring with systemd-analyze security, and the pitfalls around 226/NAMESPACE, JIT runtimes, PrivateTmp and DynamicUser.

Most services on a Linux host live in a systemd unit with little more than ExecStart, User and Restart. Such a process can read every file its user can reach, open sockets in any address family, share files with other processes through /tmp, and call almost every system call the kernel offers. When a remote code execution bug shows up in the application, the attacker inherits far more room than the service ever needed. systemd ships dozens of options to shrink that room, most of them one line long. The trouble is that people add them without knowing which one breaks what.

Three approaches

Do nothing. Run the service under its own user and stop there. That is a baseline, but file permissions remain the only line of defense. World-readable config files, other services' sockets and home directories stay exposed.

Move it into a container. Namespaces, cgroup limits and a seccomp profile come for free. But adding a container runtime, an image pipeline and a registry purely for isolation is a disproportionate cost for a service that ships as a single binary, and default container profiles are often broader than necessary anyway.

Use systemd's own hardening options. The same kernel mechanisms a container runtime uses (namespaces, capability sets, seccomp) can be switched on from the unit file. For services installed from a package manager, single binaries or script-based daemons, this has the best cost to benefit ratio. That is the recommendation here: if you are not already running containers, do not adopt them just for isolation. Harden the unit first.

A starting template

The drop-in below is a reasonable start for an ordinary service that listens on the network and writes to its own data directory. Put it in the file opened by systemctl edit app.service, so the unit shipped by the package stays untouched.

[Service]
DynamicUser=yes
StateDirectory=app
CacheDirectory=app
LogsDirectory=app

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes

ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes

RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes

CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
UMask=0077

The reasoning behind each choice matters more than the list.

ProtectSystem=strict mounts the whole file system read-only. The places the service must write to are opened with StateDirectory, CacheDirectory and LogsDirectory; systemd creates them under /var/lib, /var/cache and /var/log and hands ownership to the service user. Using these instead of a hand-maintained ReadWritePaths list means the directory is born with the service and its ownership is fixed on every start.

DynamicUser=yes allocates a transient user at each start. The manual user creation step disappears, and the service's files never end up co-owned by another account.

An empty CapabilityBoundingSet= means the process cannot hold any capability, even if it is started as root. If the service needs to bind a port below 1024, grant only that:

CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

SystemCallFilter=@system-service is a predefined group covering what a typical service calls. SystemCallErrorNumber=EPERM makes a disallowed call return an error instead of killing the process. The default is termination with SIGSYS, which makes the first round of debugging far harder than it needs to be.

Measure as you go

systemd reports how exposed a unit is:

systemd-analyze security app.service

The output lists every option and ends with an exposure score from 0 to 10. Treat the score as a checklist for options you forgot, not as a target. Some options are meaningless for your service, and enabling them to lower the number buys nothing.

To find out whether an option breaks the service before you touch the unit, try it in a transient unit:

sudo systemd-run --pty --wait \
  -p ProtectSystem=strict -p PrivateTmp=yes -p NoNewPrivileges=yes \
  /usr/bin/app --config /etc/app/config.toml

You see the process in the foreground under the same restrictions, including which file write gets refused.

Pitfalls

A missing path stops the service from starting. If ReadWritePaths, ReadOnlyPaths or InaccessiblePaths lists a path that does not exist, the service fails with exit status 226/NAMESPACE. The message does not name the path; it only says the namespace could not be set up. Prefix optional paths with - and they are ignored when absent.

Unknown options are silently ignored. An older systemd logs a warning for a key it does not recognize and carries on. Copy a template written on a recent distribution to an older host and the service starts fine, but part of the protection was never applied. Check the template on every target version with systemd-analyze verify and systemd-analyze security.

MemoryDenyWriteExecute=yes breaks JIT runtimes. It forbids memory that is both writable and executable. Java, Node.js, .NET and PHP with JIT enabled will refuse to start or crash in odd ways. It is safe for compiled Go or Rust binaries; for anything interpreted or JIT compiled, test first.

PrivateTmp=yes breaks the shared /tmp assumption. If the service drops a socket or temp file in /tmp and expects another process to find it, that no longer works, because the service's /tmp is its own. The directory is also wiped when the service restarts. For shared sockets use RuntimeDirectory and configure the path explicitly.

With DynamicUser, files written elsewhere can change owner. systemd fixes ownership under StateDirectory at each start. Files written to some other directory opened with ReadWritePaths keep the transient UID; if the service gets a different one next time, it cannot read its own files. Keep persistent data only in directories systemd manages.

ExecStart accumulates in drop-ins. Writing ExecStart in a drop-in tries to add a second command, and a normal service unit rejects that. To replace the command, first clear the list with an empty ExecStart= line. The same applies to other list-valued options.

A tight RestrictAddressFamilies can break name resolution. Remove AF_UNIX and library calls that talk to the local resolver over a Unix socket start failing. The symptom looks like the service cannot reach an external host; the real cause is that the local socket was closed off.

When not to do this

If the service already runs on a container orchestrator, define the same restrictions in that platform's security context. Restrictions managed in two layers make it hard to tell which one caused a failure. If the service launches child processes as other users (a login shell, a CI runner, a backup tool), NoNewPrivileges and an empty capability set will block its core job; such services need options enabled one at a time, not the generic template. And if nobody will maintain the unit as the software evolves, remember that each release may want a new path or a new system call. Hardening is not a write once setting; it belongs on the test list for every upgrade.

For the largest return with the least breakage, start with ProtectSystem=strict, ProtectHome, PrivateTmp, NoNewPrivileges and an empty CapabilityBoundingSet. Those five almost never break an ordinary service, and they sharply reduce how far an attacker can move after an exploit. Add the rest one by one, guided by the systemd-analyze security output.