Many homelab administrators focus on network firewalls, container security, and kernel parameters — but overlook the sandboxing features built into systemd itself. Every service that runs on your Linux host can be locked down with zero code changes, using systemd’s security directives.
The systemd-analyze security command exposes the current exposure level of every unit on your system, scoring each one on a scale from 0 (safe) to 10 (exposed). By default, most services score 9+. With the directives in this guide, you can push them to 3 or lower.
This guide covers the security directives that matter, how to audit your current services, how to apply hardening through drop-in overrides without touching vendor unit files, and real hardened configurations for common homelab daemons like Nginx, Prometheus, and Docker containers.
Every command and config works on systemd 250+ (Ubuntu 22.04+, Debian 12+, Fedora 38+) and most will work on systemd 240+ (Ubuntu 20.04+).
systemd Security Directives — The Essential Set
systemd provide around 40 security-related directives for [Service] sections. Most fall into four categories: filesystem isolation, process isolation, capability reduction, and resource limits. These are the ones that give you the most security per line of config.
Filesystem Sandboxing
|
|
| Directive | What it does | Effect |
|---|---|---|
ProtectSystem=strict |
Makes /usr and /etc read-only. The service can only write to explicitly allowed paths. |
Blocks malware from modifying system binaries or config. |
ProtectSystem=full |
Makes /usr read-only, /etc read-only, /var writable. |
Good balance for services that need to write logs to /var/log. |
ProtectHome=true |
Makes /home, /root, /run/user inaccessible. The process sees empty directories. |
Prevents reading SSH keys, user documents, and dotfiles. |
PrivateTmp=true |
Mounts a private /tmp and /var/tmp namespace. |
Prevents /tmp race-condition exploits and temp file snooping. |
PrivateDevices=true |
Provides a minimal /dev with only null, zero, random, and urandom. |
Blocks direct hardware access. |
ProtectKernelTunables=true |
Makes /sys and /proc/sys read-only. |
Blocks kernel parameter changes from the service. |
ProtectKernelModules=true |
Blocks loading or listing kernel modules. | Prevents kernel module escalation. |
ProtectControlGroups=true |
Makes cgroup filesystems read-only. | Prevents escaping cgroup isolation. |
Process Isolation
|
|
| Directive | What it does |
|---|---|
NoNewPrivileges=true |
Blocks the process and its children from gaining new privileges via setuid, setcap, or capability-granting syscalls. This is the single most impactful hardening directive. It is essentially non-negotiable. |
MemoryDenyWriteExecute=true |
Blocks mmap(PROT_EXEC) on writable memory. Prevents JIT spray and shellcode injection. Breaks some JIT compilers (Python with JIT, V8). |
RestrictSUIDSGID=true |
Blocks chmod and chown on setuid/setgid bits. |
LockPersonality=true |
Prevents changing the execution domain via personality(). |
Capability Reduction
|
|
CapabilityBoundingSet is a whitelist of Linux capabilities the service can ever use. Start with an empty set and add only what the service needs:
|
|
To find out what capabilities a specific service needs, run it without CapabilityBoundingSet, then check the journal:
|
|
If the service logs something like “Operation not permitted” at startup, add the missing capability and retry.
Network and Syscall Filtering
|
|
| Directive | What it does |
|---|---|
RestrictAddressFamilies |
Whitelist of socket address families. A service that only connects to localhost doesn’t need AF_INET or AF_INET6. |
SystemCallFilter |
Whitelist or blacklist of syscalls. @system-service is a systemd-maintained set appropriate for most daemons. |
SystemCallArchitectures=native |
Only allow native syscalls. Blocks 32-bit syscalls on 64-bit systems, preventing some compatibility-layer exploits. |
IPAddressDeny=any |
Blocks all IPv4 and IPv6 traffic. Pair with IPAddressAllow= to whitelist specific addresses. |
Resource Limits
|
|
These prevent a single compromised service from DoSing the host by exhausting memory, processes, or file descriptors.
Auditing with systemd-analyze security
Before hardening anything, establish a baseline. The systemd-analyze security command evaluates every active unit and assigns a numeric exposure level.
|
|
Example output for a default-service state:
SERVICE NAME EXPOSURE
~ sshd.service 9.6 SAFE
~ systemd-journald.service 4.6 SAFE
~ prometheus-node-exporter.service 9.3 SAFE
~ nginx.service 9.0 SAFE
A score of 9.6 means the service has almost no sandboxing. With hardening, you can bring these down to 2–3.
To inspect a single unit in detail:
|
|
This shows exactly which security directives are set (or missing) and why the score is what it is:
NAME DESCRIPTION EXPOSURE
✗ RootDirectoryOrRootImage=/… Root directory is not changed 0.1
✗ User=/DynamicUser=… Service runs as root user 0.2
✗ CapabilityBoundingSet=~CAP_SETUID CAP_SETGID … Service has no capability whitelist 0.1
…
Use this output to identify the low-hanging fruit on each service.
Applying Hardening with Drop-In Overrides
Never edit vendor unit files in /lib/systemd/system/. Package updates overwrite them. Instead, use drop-ins:
|
|
Then apply and verify:
|
|
The score should drop from ~9.0 to ~2.5 on a clean configuration.
Common breakage points and fixes:
- If Nginx fails to start with
socket()errors: addCAP_NET_RAW - If Nginx can’t read SSL certificates from a custom path: add
ReadWritePaths=/etc/ssl/private - If the service can’t write logs: change
ProtectSystem=full(notstrict) or addReadWritePaths=/var/log/nginx - If
MemoryDenyWriteExecute=truebreaks a JIT-based tool (Python, Lua, some Node.js addons): remove that line or set it tofalse
ReadWritePaths for selective writes
When ProtectSystem=strict is too restrictive, use ReadWritePaths to punch holes in the read-only filesystem:
|
|
This keeps everything else read-only while allowing the service to write to exactly the directories it needs.
Real-World Hardened Configurations
Prometheus Node Exporter (Minimal Score Target: 1.5)
Node exporter needs to read /proc, /sys, and /host filesystem paths. It does not need network access to arbitrary hosts, kernel modules, or user home directories.
|
|
Node exporter needs zero capabilities and only the standard syscall set. Its exposure drops to ~1.5.
Docker Daemon (As Locked As Possible)
Docker requires broad capabilities by nature — it manages namespaces, mounts, networking, and cgroups. But you can still clamp down:
|
|
Note: ProtectKernelModules=true and MemoryDenyWriteExecute=true may break Docker’s overlay2 storage driver. Test thoroughly.
PostgreSQL
|
|
First Steps for Every Homelab
If you want the most impact with the least effort, apply these three directives to every service:
|
|
These three alone drop most services from ~9.5 to ~3.8. They are safe for essentially every daemon — I have yet to find a common service that breaks with only these three.
From there, audit each service with systemd-analyze security and add directives incrementally:
- Run
systemd-analyze security <service>and note the issues. - Add
ProtectSystem=full→ test → if it works, move tostrict. - Add
CapabilityBoundingSet=(empty) → test → if it fails, add back capabilities one at a time from the journal errors. - Add
SystemCallFilter=@system-service→ test. - Add
MemoryDenyWriteExecute=true→ test (most likely to break JIT).
Scripted Hardening
For homelabs with tens of services, hardening each one manually is tedious. This one-liner generates drop-in files for every service on the system with the minimum safe directives:
|
|
This is a starting point. After applying it, identify the services that break (and they will — some daemons legitimately need root access across the filesystem) and remove the offending directives from their individual drop-ins.
Verification
After hardening, confirm everything is working:
|
|
With proper hardening, your systemd services run with the minimum filesystem access, the minimum capabilities, and the minimum syscall surface needed to do their jobs. No root access to your home directory, no writable system binaries, no privilege escalation vectors. And nothing depends on custom tooling — it is all built into the init system that already manages every service on your Linux host.
Apply these changes incrementally, test each one, and within an afternoon you can drop your average service exposure from 9 to 3 without touching a single line of application code.