A systemd service unit can confine itself in ways a container cannot: it can make parts of the filesystem read-only, hide /home entirely, give itself a private /tmp, drop the entire capability set, and filter syscalls with seccomp — all while running as a normal, unprivileged user. The directives live in the [Service] section and most of them are one line each. The hard part is knowing which combination is worth having, and which directives do nothing on your kernel or inside your container.
The reference below groups the directives by what they actually restrict: privilege escalation, filesystem writes, process and device visibility, capabilities, and syscalls. Start from systemd-analyze security, which scores a unit out of 10.0 for exposure, apply the highest-value directives first, then re-score. The two that give the most for the least are NoNewPrivileges= and ProtectSystem=strict with explicit write exceptions — but note that hardening a unit which already runs as root achieves much less than hardening one that does not.
Key takeaways:
systemd-analyze security <unit>scores a unit from 0.0 (most confined) to 10.0 (least). Start there, not with a copied config.- Running as an unprivileged user plus
NoNewPrivileges=and a restrictedCapabilityBoundingSet=is the base. Filesystem directives are the second layer. ProtectSystem=strictmakes the whole filesystem read-only except/dev,/procand/sys. You will needReadWritePaths=or the service will not start.ProtectHome=andPrivateTmp=remove entire attack surfaces rather than restricting them, which makes them high value and low risk.- Several directives are no-ops inside containers because the runtime already provides that confinement. Duplicating it buys nothing and complicates debugging.
- Test with
systemd-analyze securitybefore and after, and read the service’s own logs after each change. Hardening is a sequence of small experiments, not one edit.
Start with the score
systemd ships an analyser that reads a unit and reports how much attack surface it leaves exposed, broken down by category, scored from 0.0 to 10.0 where lower is better:
systemd-analyze security myapp.service
systemd-analyze security --no-pager myapp.service
# Assess a unit file that is not installed yet
systemd-analyze security --offline=yes ./myapp.service
# Machine-readable, for trending across a fleet
systemd-analyze security --json=short myapp.service
The output is per-test, which is the useful part: it tells you which specific checks failed rather than only that the number is high. Use it as the loop. Harden, re-score, and stop when the remaining tests are ones you have a reason to accept.
For fleet-wide work, the --security-policy= option lets you set an expected score and have the analyser report deviations, which turns hardening into something you can enforce in CI rather than remember to do.
A worked example
[Unit]
Description=Example application
After=network-online.target
[Service]
Type=simple
User=appuser
Group=appgroup
# --- Privilege ---
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
# --- Filesystem ---
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ReadWritePaths=/var/lib/app /var/log/app
StateDirectory=app
LogsDirectory=app
# --- Process visibility ---
ProtectProc=invisible
ProcSubset=pid
# --- Syscalls and memory ---
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
MemoryDenyWriteExecute=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
[Install]
WantedBy=multi-user.target
That is a strong starting point, not a finished answer. Two notes on ordering, because they are the usual failure: ProtectSystem= and the path exceptions are order-independent in modern systemd, but if you set ProtectSystem=strict and forget ReadWritePaths=, the service will fail at startup with a permission error that does not obviously point at the hardening you just added. Set the path exceptions in the same edit.
The second note is that StateDirectory= and LogsDirectory= are worth preferring to hand-written paths, because systemd creates them with the correct ownership and mode under /var/lib and /var/log and cleans them up on stop where appropriate.
The directives, by what they restrict
Privilege escalation
| Directive | Values | Effect |
|---|---|---|
NoNewPrivileges= | yes / no | Prevents the service and its children from gaining privileges through setuid and file capability bits at exec time. Cheapest high-value hardening there is |
PrivateUsers= | yes / no | Maps the service’s users to an unprivileged user namespace, so root inside the unit is not root outside |
RestrictSUIDSGID= | boolean | Refuses to create setuid or setgid files |
RestrictRealtime= | boolean | Refuses realtime scheduling privileges |
LockPersonality= | boolean | Forbids changing the execution domain, closing a class of code-injection tricks |
Filesystem
| Directive | Values | Effect |
|---|---|---|
ProtectSystem= | no, yes, full, strict | yes/full make /usr, /boot and /efi read-only. strict additionally makes the entire filesystem hierarchy read-only except /dev, /proc and /sys |
ProtectHome= | no, yes, read-only, tmpfs | Hides /home, /root and /run/user entirely, or mounts them read-only, or as an empty tmpfs |
PrivateTmp= | boolean | Gives the unit a private /tmp and /var/tmp, isolated from the host and from other services |
PrivateDevices= | boolean | Provides only a minimal set of device nodes; physical devices such as disks are not exposed |
ReadWritePaths= | space-separated paths | Exceptions to ProtectSystem=strict. Required, or the service cannot write anywhere |
ReadOnlyPaths= | space-separated paths | Additional paths made read-only |
InaccessiblePaths= | space-separated paths | Paths made entirely inaccessible — stronger than read-only, since they cannot be opened or even stat-ed |
UMask= | octal | Sets the umask for files the service creates, rather than inheriting yours |
ProtectHome=yes deserves a flag for network services specifically. A compromised web server that cannot see /home has lost the filesystem location of SSH keys, shell histories and source code in one line. It is among the highest value-to-risk ratios available.
Process and network visibility
| Directive | Values | Effect |
|---|---|---|
ProtectProc= | noaccess, invisible, default, ptracable | Restricts what the unit can see in /proc. invisible hides other users’ processes; noaccess makes /proc inaccessible entirely |
ProcSubset= | pid, tid, all | Limits /proc to process IDs only, hiding a large amount of kernel and device detail |
IPAddressAllow= / IPAddressDeny= | CIDR or address lists | Filters the unit’s network access by address, using a BPF cgroup program |
RestrictAddressFamilies= | e.g. AF_UNIX AF_INET AF_INET6 | Restricts which socket address families the unit may create |
Capabilities and syscalls
| Directive | Effect |
|---|---|
CapabilityBoundingSet= | Removes capabilities from the bounding set, so the unit can never acquire them. An empty value removes all of them |
AmbientCapabilities= | Grants specific capabilities deliberately, for a service that genuinely needs one such as CAP_NET_BIND_SERVICE |
SystemCallFilter= | Restricts syscalls via seccomp, with syscall sets such as @system-service a practical starting point |
SystemCallErrorNumber= | The errno returned for blocked calls. Defaults vary; setting EPERM explicitly makes failures predictable |
SystemCallArchitectures= | Restricts which syscall ABI the unit may use |
MemoryDenyWriteExecute= | Forbids writable and executable memory at the same time, which blocks a large family of code-injection techniques. Breaks JIT runtimes |
RestrictNamespaces= | Blocks creation of the listed namespace types: mnt, pid, net, ipc, uts, user, cgroup |
What will break
Hardening fails in predictable ways, and knowing them in advance saves an afternoon.
| Directive | Common breakage |
|---|---|
ProtectSystem=strict | Service cannot write its data directory, and fails at startup with a bare permission error |
ProtectHome=yes | Anything reading configuration or certificates out of a user’s home directory |
MemoryDenyWriteExecute=yes | JIT compilers and runtimes. Node, V8-based tooling, Python with C extensions that JIT, and anything using mmap PROT_EXEC for code |
SystemCallFilter=@system-service | Less common syscalls the set does not include, sometimes only on specific kernel versions |
ProtectProc=invisible | Monitoring tools that need to see other processes, and some container runtimes |
CapabilityBoundingSet= empty | Binding to ports below 1024 without CAP_NET_BIND_SERVICE |
PrivateTmp=yes | Anything expecting to share a path in /tmp with another unit |
In containers, some of this is already done
If the service runs inside a container, the runtime has already provided some of this. A container without added privileges has no capabilities to lose, so CapabilityBoundingSet= achieves nothing. A read-only root filesystem makes ProtectSystem=strict partly redundant. Namespace isolation makes RestrictNamespaces= mostly moot.
The directives are not wrong in that context — they document intent and they protect against a future change in how the container is launched. But do not expect them to move the score much, and do not treat a hardened unit as a substitute for a correctly configured container. They are defence in depth across two boundaries, and each boundary should be doing its own job.
A method that works
- Run as a dedicated unprivileged user first. Everything below is worth much more once
User=is set and the service no longer starts as root. - Score it.
systemd-analyze security myapp.service. Save the number. - Apply three directives.
NoNewPrivileges=yes,ProtectHome=yes,PrivateTmp=yes. Restart. Read the logs. Confirm it works. - Add filesystem confinement.
ProtectSystem=strictplus theReadWritePaths=your service actually needs. Restart. Confirm. - Drop capabilities. Empty the bounding set, then add back only what the service demonstrably needs.
- Filter syscalls last. It is the directive most likely to break something subtle and least likely to be the thing that was actually compromised.
- Re-score and record the exceptions. A hardening change with a comment explaining each accepted risk is maintainable. A bare block of directives is not.
For a worked example of the same philosophy applied to an automated workload rather than a service, building a sandboxed AI agent on Ubuntu with Docker covers the container-side boundary, and AI agent security on Linux covers why least privilege is the control everything else assumes.
References
- systemd.exec(5) — execution environment configuration, the authoritative directive reference
- systemd-analyze(1) — including the
securitysubcommand and its options - systemd.service(5) — service unit options
- systemd.resource-control(5) — the companion reference for CPU, memory and task limits
- systemd.exec(5) on freedesktop.org — upstream documentation (note: this host rejects automated requests, so verify against a mirror)
Related Tools
Try these companion tools on DevStackIO Tools:
- CSP generator — build Content Security Policy headers to harden services against XSS.
ukkohhsoshznopkmnjffjjxkgvsxeo