Systemd Service Hardening: The Options That Actually Reduce Attack Surface
ProtectSystem, PrivateTmp, NoNewPrivileges, CapabilityBoundingSet — which options meaningfully constrain a service and which are theater for the audit report.

Our security audit checklist had twelve systemd hardening directives pre-checked. I spent a week on a Debian 12 / systemd 252 test bench determining which ones actually constrained a compromised service and which ones existed to satisfy checkbox compliance.
Short answer: NoNewPrivileges, PrivateTmp, ProtectSystem=strict, CapabilityBoundingSet, and SystemCallFilter do real work. Several others are context-dependent or theater on typical app workloads.
Test methodology
Target service: real production binary — inventory-api, Go static binary, binds :8080, reads /etc/inventory/config.toml, writes logs to /var/log/inventory/, needs outbound PostgreSQL and Redis.
Attack simulation (controlled):
- Local privilege escalation via setuid helper dropped in writable path
- Attempt read
/etc/shadow, write/usr/bin, load kernel module - Attempt connect to metadata IP
169.254.169.254 - Fork bomb and
chmod 777 /
Each hardening option enabled in isolation on VM snapshots, then in combination. systemd 252.19-1~deb12u1.
Options that meaningfully reduce attack surface
NoNewPrivileges=yes
Blocks setuid/setgid escalation. Exploit path using a dropped setuid binary failed immediately. Zero app compatibility issues for our Go services.
Use everywhere unless you run something that legitimately exec's setuid helpers (rare).
CapabilityBoundingSet=
Stripped all capabilities except CAP_NET_BIND_SERVICE for sub-1024 bind (we moved to :8080 behind nginx — stripped everything).
Without bounding: compromised service used CAP_DAC_READ_SEARCH inherited from parent unit file mistake. With clean bounding: read outside allowed paths blocked.
Verify with getpcaps $(pidof inventory-api) after deploy.
ProtectSystem=strict
Mounts /usr, /boot, /etc read-only from service perspective. Write to /etc/passwd failed. App needed explicit ReadWritePaths=/var/lib/inventory for state.
ProtectSystem=full (default in many hardening guides) was insufficient — service could still write to some /etc subpaths via symlinks we hadn't audited.
PrivateTmp=yes
Service sees isolated /tmp. Symlink attacks against other services' temp files blocked. Our app didn't use /tmp — free win.
SystemCallFilter=@system-service
Used @system-service allowlist plus explicit @chown removal. Blocked module-load syscalls in exploit test. Broke one Java service that used clone flags JVM expected — had to customize filter per runtime.
Go, Rust binaries: no issues. Node 20: required adding @memlock for old native addon.
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
Blocked attempt to open AF_NETLINK socket for recon. Broke nothing on our HTTP microservices.
Options that are situational or theater
ProtectHome=yes
Reads /home inaccessible. Meaningful on dev machines; on prod servers with no home dirs — no measurable effect. Not harmful; low value.
ProtectKernelTunables=yes / ProtectControlGroups=yes
Good for multi-tenant hostile workloads. Our services weren't touching /proc/sys or cgroups directly — blocked exploit paths we simulated but those paths weren't reachable anyway without CAP_SYS_ADMIN.
Keep enabled — low compatibility cost on systemd 252.
MemoryDenyWriteExecute=yes
Should prevent RW→RX memory — good against shellcode. Go runtime and JVM break (JIT, stack growth). Disabled for managed runtimes. Enabled for static Go builds that don't need JIT — marginal gain, we skip for uniformity.
LockPersonality / NoNewPrivileges / SELinux
Not systemd but often grouped. We run AppArmor on some hosts; duplicate with systemd sandbox — pick coherent stack, not both randomly.
Example unit fragment (what we standardized)
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/inventory
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
LimitNOFILE=65536
Java units get a separate template with relaxed SystemCallFilter and no MemoryDenyWriteExecute.
AppArmor overlap audit
On Ubuntu hosts running both AppArmor profiles and systemd sandboxing, we found redundant restrictions on /proc access and conflicts when AppArmor denied what systemd already blocked — error messages pointed to AppArmor, wasted debug time.
Standardized: systemd sandbox on systemd-managed units; Docker/k8s workloads use container profiles only. Document in host runbook which layer owns what.
Rollout metrics
After deploying hardened unit template to 847 services over 8 weeks:
- Compatibility failures: 23 services (all JVM or legacy C++ with
personalitysyscalls) - Incident reduction: not measured as CVE prevented — measured as red team exercise success rate dropped from 4/6 priv-esc paths to 1/6 on hardened sample
- Deploy time: +0 seconds — template in base role
Interaction with containers
Same binary in Docker — systemd directives don't apply inside container; use k8s securityContext analogs. See cgroups memory limits. On bare metal / VM systemd services, this unit fragment is the baseline.
Audit theater we removed
SecureBits=— never configured correctly in old units; removed- Documentation claiming "full sandbox" — replaced with explicit threat model per service tier
Aligns with broader security tradeoffs roadmap — hardening without compatibility testing is outage bait.
What I'd do next
- systemd-analyze security in CI — fail if score below threshold for tier-1 services
- Automated VM exploit regression on template changes ( quarterly )
- Map each option to MITRE technique ID for audit traceability — satisfies SOC2 without checkbox fiction
Hardening options that break JVM but not Go are not "universal best practices." Test against your actual binary, not a C hello-world.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

