For service hardening on Linux, there are heavy-duty tools like AppArmor and SELinux. But in the field, the fastest “impact per effort” usually comes from systemd unit sandboxing: you shrink the permission surface without modifying the application.
In this article I’ll walk through a model that begins with “minimum risk” and tightens the screws gradually.
Starting point: observe first
Before you start hardening, get clarity on two questions:
- Which files does the service write to? (logs, tmp, cache, sockets)
- Which ports does it dial out / listen on?
Without that observation, settings like ProtectSystem or ReadOnlyPaths turn into “break it and burn it.”
Phase 1 — The low-risk trio
These three settings rarely cause surprises on most services:
NoNewPrivileges=truePrivateTmp=trueProtectHome=true(when the service has no need for home access)
Phase 2 — Filesystem: ProtectSystem
This is the most effective lever — and the one most likely to break things:
ProtectSystem=full(some paths read-only)ProtectSystem=strict(almost everything read-only)
At this point you explicitly declare where your service is allowed to write:
ReadWritePaths=/var/lib/myapp /var/log/myappStateDirectory=myapp(lets systemd own this for you)CacheDirectory=myapp
Sample unit fragment
[Service]
DynamicUser=true
StateDirectory=myapp
CacheDirectory=myapp
LogsDirectory=myapp
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp /var/log/myapp
Phase 3 — Identity: DynamicUser
With DynamicUser=true, the service runs in isolation without provisioning a permanent system user. Wins I’ve seen in the field:
- the “accidentally shared user” pattern goes away
- the permission surface narrows
- the service leaves no trace once removed
But watch out:
- If the service must persist files, use a systemd-managed directory like
StateDirectory= - Some applications expect a “stable” UID/GID — that’s a case for an exception
Phase 4 — Linux capabilities and syscall filters
The most important principle: keep the capability list minimal.
Example:
- A web service typically only needs
CAP_NET_BIND_SERVICE(if it has to listen on 80/443).
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
Syscall filters are the next level up:
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
Phase 5 — Network restrictions (when needed)
If the application only uses certain address families:
RestrictAddressFamilies=AF_INET AF_INET6
If the service “shouldn’t go out at all,” tighter restrictions are an option, but the breakage risk grows. I’d recommend observing egress first.
Rollout and rollback
I roll out hardening changes through a “release ring” too:
- Canary: 1 host
- Pilot: 5–10%
- Prod: the rest
The rollback plan should be simple:
- Revert the unit drop-in file
systemctl daemon-reload && systemctl restart
Closing: hardening should be managed like a product
systemd sandboxing is not a “one-shot security task” — it should be a part of the service lifecycle. The best results come in small, measured steps: which setting reduced which risk, and at what operational cost?