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 restricted CapabilityBoundingSet= is the base. Filesystem directives are the second layer.
  • ProtectSystem=strict makes the whole filesystem read-only except /dev, /proc and /sys. You will need ReadWritePaths= or the service will not start.
  • ProtectHome= and PrivateTmp= 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 security before 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

DirectiveValuesEffect
NoNewPrivileges=yes / noPrevents 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 / noMaps the service’s users to an unprivileged user namespace, so root inside the unit is not root outside
RestrictSUIDSGID=booleanRefuses to create setuid or setgid files
RestrictRealtime=booleanRefuses realtime scheduling privileges
LockPersonality=booleanForbids changing the execution domain, closing a class of code-injection tricks

Filesystem

DirectiveValuesEffect
ProtectSystem=no, yes, full, strictyes/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, tmpfsHides /home, /root and /run/user entirely, or mounts them read-only, or as an empty tmpfs
PrivateTmp=booleanGives the unit a private /tmp and /var/tmp, isolated from the host and from other services
PrivateDevices=booleanProvides only a minimal set of device nodes; physical devices such as disks are not exposed
ReadWritePaths=space-separated pathsExceptions to ProtectSystem=strict. Required, or the service cannot write anywhere
ReadOnlyPaths=space-separated pathsAdditional paths made read-only
InaccessiblePaths=space-separated pathsPaths made entirely inaccessible — stronger than read-only, since they cannot be opened or even stat-ed
UMask=octalSets 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

DirectiveValuesEffect
ProtectProc=noaccess, invisible, default, ptracableRestricts what the unit can see in /proc. invisible hides other users’ processes; noaccess makes /proc inaccessible entirely
ProcSubset=pid, tid, allLimits /proc to process IDs only, hiding a large amount of kernel and device detail
IPAddressAllow= / IPAddressDeny=CIDR or address listsFilters the unit’s network access by address, using a BPF cgroup program
RestrictAddressFamilies=e.g. AF_UNIX AF_INET AF_INET6Restricts which socket address families the unit may create

Capabilities and syscalls

DirectiveEffect
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.

DirectiveCommon breakage
ProtectSystem=strictService cannot write its data directory, and fails at startup with a bare permission error
ProtectHome=yesAnything reading configuration or certificates out of a user’s home directory
MemoryDenyWriteExecute=yesJIT compilers and runtimes. Node, V8-based tooling, Python with C extensions that JIT, and anything using mmap PROT_EXEC for code
SystemCallFilter=@system-serviceLess common syscalls the set does not include, sometimes only on specific kernel versions
ProtectProc=invisibleMonitoring tools that need to see other processes, and some container runtimes
CapabilityBoundingSet= emptyBinding to ports below 1024 without CAP_NET_BIND_SERVICE
PrivateTmp=yesAnything expecting to share a path in /tmp with another unit
Expected failure modes, not defects. Each one is a decision you either accept or work around.

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=strict plus the ReadWritePaths= 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

Related Tools

Try these companion tools on DevStackIO Tools:

  • CSP generator — build Content Security Policy headers to harden services against XSS.