systemd hardening: taking a Python service from 9.6 to 1.7

TL;DR
systemd-analyze security is an underused tool. It scans your unit files and computes an exposure score from 0.0 (UNSAFE) to 10.0 (PERFECT). Custom Python services often score around 9.6 by default — that’s bad.
I took my hugo-mcp service (FastAPI exposing 7 MCP tools) from 9.6 → 1.7 without breaking a single feature. Here are the directives that actually matter, and the ones that are traps.
Initial score
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 9.6 UNSAFE 😨9.6 out of 10. The service runs root-equivalent, can setuid(), can allocate RWX memory, sees all system processes, can write everywhere, can make arbitrary syscalls.
For an MCP that edits Markdown content and triggers a Hugo build, that’s massive overkill.
The directives that actually matter
1. System isolation
[Service]
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectHostname=true
ProtectProc=invisible
ProcSubset=pidImpact: the service only sees /proc/<its-pid>/, can’t touch the kernel, cgroups, or system clock.
2. Linux capabilities
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=trueThe service runs with zero capabilities. No CAP_NET_BIND_SERVICE, no CAP_SYS_ADMIN. If you listen on a port < 1024, use socket activation or setcap on the binary — not an ambient capability.
3. Filesystem write whitelist
ReadWritePaths=/home/jm/hugo-mcp /home/jm/hugo-site
ReadOnlyPaths=/etc
PrivateMounts=trueThe service can write to ONLY 2 specific folders. Any other open(O_WRONLY) returns EROFS. Even if someone finds an RCE in FastAPI, the attacker can’t modify /etc/passwd or /usr/local/bin/.
4. Network egress whitelist
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
IPAddressAllow=192.168.122.1/32
# Cloudflare API (cache purge)
IPAddressAllow=104.16.0.0/12
IPAddressAllow=2606:4700::/32The most powerful and trickiest directive. The service can only reach loopback, libvirt bridge, and Cloudflare ranges.
Trap #1: if you forget external ranges your service calls (Cloudflare API, GitHub, your log broker), you’ll get silent timeouts that are hard to debug. I spent ~2h figuring out why purge_cache was timing out at 5s when manual SSH worked: IPAddressDeny=any was active without Cloudflare whitelisting.
Trap #2: IPAddressAllow only supports IPv4/IPv6 ranges, not hostnames. So if Cloudflare changes its ranges, you have to update the unit file.
5. Syscall filter
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native@system-service is a predefined set of “reasonable” syscalls for a service. Blocks anything niche (mount, swapon, reboot…).
6. Bonus
LockPersonality=true # blocks setpersonality()
RestrictRealtime=true # no realtime scheduling
RestrictSUIDSGID=true # no setuid file creation
RemoveIPC=true # IPC cleanup on stop
RestrictNamespaces=true # no unshare(CLONE_NEWNS)
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true # blocks mmap PROT_WRITE|PROT_EXEC
UMask=0077 # files created without group/worldMemoryDenyWriteExecute=true is interesting: it blocks RWX mmap, which breaks most JIT-style shellcodes. For a pure Python service (no JIT), zero functional impact.
The UMask=0077 trap
This directive creates ALL files as 0600 (read/write only for the user). Innocent-looking.
Except my hugo-mcp service writes to public/ (Hugo build folder), and nginx (which serves these files) runs as a different user. Result:
$ ls -la public/
-rw------- 1 hugo-mcp hugo-mcp 4823 May 9 14:23 index.htmlnginx can no longer read index.html. The site returns 403 Forbidden on everything after each rebuild.
Fix: add chmod -R o+rX public/ in deploy.sh after each build:
hugo --minify
chmod -R o+rX public/ # ← essential after UMask=0077
rsync -a public/ /var/www/hugo/Final score
$ sudo systemd-analyze security hugo-mcp
→ Overall exposure level for hugo-mcp.service: 1.7 EXPOSED 😱From 9.6 UNSAFE to 1.7 EXPOSED. The “EXPOSED” label still sounds threatening, but systemd-analyze just considers any service that listens on a port to be exposed by nature. Going lower means socket activation + non-privileged User= + full namespace isolation, which no longer makes sense for an active network service.
What systemd-analyze doesn’t tell you
The systemd score only covers kernel isolation. It says nothing about:
- Application security (auth, input validation, rate limiting…)
- Secrets in
/etc/secrets/(readable by root via another path) - Logs that might leak tokens
- Python deps (CVE in a cached lib)
It’s just one layer in a defense-in-depth strategy. But it’s a cheap layer that hardens the default posture significantly.
Recipe applicable to any Python service
If you have a Python systemd service, here’s a drop-in you can adapt in 5 minutes:
sudo systemctl edit my-service[Service]
# Isolation
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectProc=invisible
ProcSubset=pid
# Capabilities
CapabilityBoundingSet=
AmbientCapabilities=
# Filesystem
ReadWritePaths=/home/user/data
PrivateMounts=true
# Network
IPAddressDeny=any
IPAddressAllow=127.0.0.0/8
# Add the external ranges you need
# Syscalls
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# Bonus
LockPersonality=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true
RestrictNamespaces=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
MemoryDenyWriteExecute=true
UMask=0077Then:
sudo systemctl daemon-reload
sudo systemctl restart my-service
sudo systemd-analyze security my-serviceIf the service refuses to start, check journalctl -u my-service -n 50 — usually it’s IPAddressDeny=any blocking a forgotten external API call.
Conclusion
systemd hardening is an underrated ops discipline. 30 minutes of investment to go from 9.6 to 1.7 on a service. Almost always worth it.
For the ongoing MCP security sprint, this is just the foundation. Still to come: application-level rate limiting, structured JSON audit logs, strict Pydantic validation, token rotation, internal TLS. But none of those layers would make sense on a 9.6 UNSAFE service.
You harden the OS first. The code comes after.