systemd Cheatsheet

Sandboxing and Limits

Use this systemd reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Identity and Privileges

Everything in this topic goes in [Service]. Start here, because dropping root is worth more than every other directive combined.

DirectiveValuesEffect
User=, Group=name or numeric IDRun as this account. Fails 217/USER if it does not exist
SupplementaryGroups=listExtra groups, appended unless reset with an empty assignment
DynamicUser=booleanTransient UID/GID allocated at start, released at stop
NoNewPrivileges=booleanBlock execve() from ever gaining privileges. SUID bits stop working
CapabilityBoundingSet=listCeiling on capabilities. Prefix with ~ to invert
AmbientCapabilities=listGrant specific capabilities to a non-root process
UMask=octalDefault 0022 for system units
SecureBits=listkeep-caps, noroot, no-setuid-fixup and their locked variants
[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes
CapabilityBoundingSet=

An empty CapabilityBoundingSet= means no capabilities at all, the right default for an ordinary daemon. To bind port 80 as a non-root user, grant exactly one:

[Service]
User=myapp
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

AmbientCapabilities= without the matching CapabilityBoundingSet= entry silently gives you nothing, because the bounding set is a ceiling. NoNewPrivileges=yes does not interfere with ambient capabilities, so keep it on.

Filesystem Isolation

DirectiveValuesEffect
PrivateTmp=booleanPrivate /tmp and /var/tmp, wiped on stop
ProtectSystem=no, yes, full, strictyes = /usr and boot read-only, full adds /etc, strict makes the whole filesystem read-only
ProtectHome=no, yes, read-only, tmpfsyes makes /home, /root and /run/user empty and inaccessible
ReadWritePaths=pathsPunch writable holes through ProtectSystem=strict
ReadOnlyPaths=pathsForce specific paths read-only
InaccessiblePaths=pathsMake paths vanish entirely
NoExecPaths=, ExecPaths=pathsDeny or allow execution per path
TemporaryFileSystem=mountsOverlay a tmpfs, for example /var:ro
PrivateDevices=booleanOnly /dev/null, /dev/zero, /dev/random and friends
DevicePolicy=, DeviceAllow=policyFine-grained device access
BindPaths=, BindReadOnlyPaths=src:destBind-mount something in
RootDirectory=, RootImage=pathChroot or image-based root
MountFlags=shared, slave, privateMount propagation for the unit's namespace

The workhorse combination:

[Service]
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
StateDirectory=myapp
ReadWritePaths=/srv/myapp/uploads

ProtectSystem=strict is the one that breaks services, and always for the same reason: something needs to write and you did not list it. Prefer StateDirectory= and friends over ReadWritePaths=, because they also create the directory with the right owner. Note that these are per-unit mount namespaces, so the paths still look normal from outside.

Kernel and Namespace Hardening

DirectiveValuesEffect
ProtectKernelTunables=boolean/proc/sys, /sys, /proc/sysrq-trigger read-only
ProtectKernelModules=booleanDeny explicit module load and unload
ProtectKernelLogs=booleanDeny the kernel log ring buffer (v244+)
ProtectControlGroups=boolean/sys/fs/cgroup read-only
ProtectClock=booleanDeny writes to the hardware and system clock (v245+)
ProtectHostname=booleanPrivate UTS namespace, hostname changes blocked (v242+)
ProtectProc=noaccess, invisible, ptraceable, defaultHide other processes in /proc (v247+)
ProcSubset=all, pidHide non-process files in /proc (v247+)
PrivateUsers=booleanPrivate user namespace, only the unit's own UID mapped
PrivateNetwork=booleanOnly loopback. Nothing reaches the network
PrivateIPC=booleanPrivate IPC namespace (v248+)
RestrictNamespaces=boolean or listBlock unshare()/clone() namespace types (v233+)
RestrictSUIDSGID=booleanDeny setting SUID/SGID bits (v242+)
RestrictRealtime=booleanDeny realtime scheduling
LockPersonality=booleanFreeze the personality(2) execution domain (v235+)
MemoryDenyWriteExecute=booleanNo W+X mappings. Breaks most JITs (v231+)
RemoveIPC=booleanClean up SysV and POSIX IPC objects on stop

Syscall and Socket Filters

DirectiveEffect
SystemCallFilter=@system-serviceSeccomp allow-list. The @system-service set is the sane baseline for a daemon
SystemCallFilter=~@privileged @resources~ inverts, making it a deny-list
SystemCallErrorNumber=EPERMReturn an errno instead of killing with SIGSYS
SystemCallArchitectures=nativeRefuse foreign-ABI syscalls, closing a filter bypass
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6Allow-list of socket families. none blocks all
RestrictFileSystems=ext4 tmpfsAllow-list of filesystem types (v250+)
IPAddressAllow=, IPAddressDeny=cgroup BPF address filtering, for example IPAddressDeny=any
SocketBindAllow=, SocketBindDeny=Restrict which ports may be bound (v249+)
[Service]
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

List the members of any group before you rely on it:

systemd-analyze syscall-filter                # all known groups
systemd-analyze syscall-filter @system-service
systemd-analyze syscall-filter @privileged

Groups worth knowing: @system-service, @basic-io, @privileged, @resources, @raw-io, @mount, @module, @reboot, @swap, @debug, @clock, @cpu-emulation, @obsolete, @keyring.

Resource Limits

Two different mechanisms, and people mix them up. Limit*= are per-process setrlimit(2) values. Everything else is a cgroup v2 control that applies to the unit as a whole.

DirectiveEffect
MemoryMax=2GHard cgroup limit. Exceeding it invokes the OOM killer
MemoryHigh=1GSoft limit, throttles reclaim instead of killing. Set it below MemoryMax=
MemoryMin=, MemoryLow=Protected floor during reclaim
MemorySwapMax=0Deny swap to this unit
CPUQuota=50%Half a core. 200% means two full cores
CPUWeight=100Relative share under contention, 1–10000, default 100
AllowedCPUs=0-3, AllowedMemoryNodes=Pin to CPUs or NUMA nodes (v244+)
TasksMax=512Max processes plus threads. infinity to disable
IOWeight=100Relative block IO share
IOReadBandwidthMax=/dev/sda 10MPer-device throughput cap
LimitNOFILE=65535Open file descriptors. The one you will actually need
LimitNPROC=, LimitCORE=, LimitSTACK=, LimitMEMLOCK=Other rlimits, soft:hard accepted
OOMScoreAdjust=-500Bias the kernel OOM killer
OOMPolicy=stopcontinue, stop or kill when a process is OOM-killed
Nice=, CPUSchedulingPolicy=, IOSchedulingClass=Scheduling priority
[Service]
MemoryHigh=1G
MemoryMax=2G
CPUQuota=200%
TasksMax=512
LimitNOFILE=65535
LimitCORE=infinity
OOMPolicy=stop

LimitCORE=infinity is required if you want the kernel to write a core dump for this unit. MemoryMax= alone is a cliff, MemoryHigh= below it gives you back-pressure and a chance to see the problem in the journal first.

systemctl show -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax myapp.service
systemd-cgtop                                  # live per-unit resource use
systemd-cgls                                   # the cgroup tree
systemctl set-property myapp.service MemoryMax=4G

Audit What You Wrote

systemd-analyze security myapp.service
systemd-analyze security --no-pager nginx.service | head -40
systemd-analyze security '*.service'

Every directive is scored, with an overall exposure level from 0.0 (hardened) to 10.0 (wide open) and a name from safe through unsafe. It reads only the unit definition, so it is safe to run anywhere, and it is the fastest way to find the one Protect* line you forgot.

Tighten in this order, restarting and testing after each step: User= and NoNewPrivileges=, then ProtectSystem= and ProtectHome=, then the Protect* kernel set, then SystemCallFilter=. Adding all of it at once leaves you with a 226/NAMESPACE or a SIGSYS and no idea which line caused it. journalctl -u myapp -p err plus systemd-analyze security usually names the culprit immediately.