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.
| Directive | Values | Effect |
|---|---|---|
User=, Group= | name or numeric ID | Run as this account. Fails 217/USER if it does not exist |
SupplementaryGroups= | list | Extra groups, appended unless reset with an empty assignment |
DynamicUser= | boolean | Transient UID/GID allocated at start, released at stop |
NoNewPrivileges= | boolean | Block execve() from ever gaining privileges. SUID bits stop working |
CapabilityBoundingSet= | list | Ceiling on capabilities. Prefix with ~ to invert |
AmbientCapabilities= | list | Grant specific capabilities to a non-root process |
UMask= | octal | Default 0022 for system units |
SecureBits= | list | keep-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
| Directive | Values | Effect |
|---|---|---|
PrivateTmp= | boolean | Private /tmp and /var/tmp, wiped on stop |
ProtectSystem= | no, yes, full, strict | yes = /usr and boot read-only, full adds /etc, strict makes the whole filesystem read-only |
ProtectHome= | no, yes, read-only, tmpfs | yes makes /home, /root and /run/user empty and inaccessible |
ReadWritePaths= | paths | Punch writable holes through ProtectSystem=strict |
ReadOnlyPaths= | paths | Force specific paths read-only |
InaccessiblePaths= | paths | Make paths vanish entirely |
NoExecPaths=, ExecPaths= | paths | Deny or allow execution per path |
TemporaryFileSystem= | mounts | Overlay a tmpfs, for example /var:ro |
PrivateDevices= | boolean | Only /dev/null, /dev/zero, /dev/random and friends |
DevicePolicy=, DeviceAllow= | policy | Fine-grained device access |
BindPaths=, BindReadOnlyPaths= | src:dest | Bind-mount something in |
RootDirectory=, RootImage= | path | Chroot or image-based root |
MountFlags= | shared, slave, private | Mount 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
| Directive | Values | Effect |
|---|---|---|
ProtectKernelTunables= | boolean | /proc/sys, /sys, /proc/sysrq-trigger read-only |
ProtectKernelModules= | boolean | Deny explicit module load and unload |
ProtectKernelLogs= | boolean | Deny the kernel log ring buffer (v244+) |
ProtectControlGroups= | boolean | /sys/fs/cgroup read-only |
ProtectClock= | boolean | Deny writes to the hardware and system clock (v245+) |
ProtectHostname= | boolean | Private UTS namespace, hostname changes blocked (v242+) |
ProtectProc= | noaccess, invisible, ptraceable, default | Hide other processes in /proc (v247+) |
ProcSubset= | all, pid | Hide non-process files in /proc (v247+) |
PrivateUsers= | boolean | Private user namespace, only the unit's own UID mapped |
PrivateNetwork= | boolean | Only loopback. Nothing reaches the network |
PrivateIPC= | boolean | Private IPC namespace (v248+) |
RestrictNamespaces= | boolean or list | Block unshare()/clone() namespace types (v233+) |
RestrictSUIDSGID= | boolean | Deny setting SUID/SGID bits (v242+) |
RestrictRealtime= | boolean | Deny realtime scheduling |
LockPersonality= | boolean | Freeze the personality(2) execution domain (v235+) |
MemoryDenyWriteExecute= | boolean | No W+X mappings. Breaks most JITs (v231+) |
RemoveIPC= | boolean | Clean up SysV and POSIX IPC objects on stop |
Syscall and Socket Filters
| Directive | Effect |
|---|---|
SystemCallFilter=@system-service | Seccomp allow-list. The @system-service set is the sane baseline for a daemon |
SystemCallFilter=~@privileged @resources | ~ inverts, making it a deny-list |
SystemCallErrorNumber=EPERM | Return an errno instead of killing with SIGSYS |
SystemCallArchitectures=native | Refuse foreign-ABI syscalls, closing a filter bypass |
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | Allow-list of socket families. none blocks all |
RestrictFileSystems=ext4 tmpfs | Allow-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 @privilegedGroups 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.
| Directive | Effect |
|---|---|
MemoryMax=2G | Hard cgroup limit. Exceeding it invokes the OOM killer |
MemoryHigh=1G | Soft limit, throttles reclaim instead of killing. Set it below MemoryMax= |
MemoryMin=, MemoryLow= | Protected floor during reclaim |
MemorySwapMax=0 | Deny swap to this unit |
CPUQuota=50% | Half a core. 200% means two full cores |
CPUWeight=100 | Relative share under contention, 1–10000, default 100 |
AllowedCPUs=0-3, AllowedMemoryNodes= | Pin to CPUs or NUMA nodes (v244+) |
TasksMax=512 | Max processes plus threads. infinity to disable |
IOWeight=100 | Relative block IO share |
IOReadBandwidthMax=/dev/sda 10M | Per-device throughput cap |
LimitNOFILE=65535 | Open file descriptors. The one you will actually need |
LimitNPROC=, LimitCORE=, LimitSTACK=, LimitMEMLOCK= | Other rlimits, soft:hard accepted |
OOMScoreAdjust=-500 | Bias the kernel OOM killer |
OOMPolicy=stop | continue, 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.