systemd Service Hardening: Sandboxing, Capabilities, and Resource Limits

A freshly installed service on Ubuntu 24.04 typically scores 8–9 on systemd-analyze security — marked EXPOSED — because the default unit grants full filesystem, network, and capability access. The same service can usually be locked down to a score of 2–3 with a dozen declarative directives: ProtectSystem, PrivateTmp, CapabilityBoundingSet, NoNewPrivileges, and MemoryMax. These are restart-safe, survive upgrades, and cost almost no performance.

Hardening matters most on shared or underpowered nodes, where one compromised service can chew through the entire VPS. If you are about to provision one, see the full specs on our VPS comparison table and plan the resource limits before you deploy.

Auditing a Unit with systemd-analyze

systemd-analyze security nginx
# → Overall exposure level for nginx.service: 8.4 EXPOSED
systemd-analyze security --offline=yes nginx.service

Run the audit first so you have a baseline score to compare against. The per-item list shows exactly which directives are missing — treat it as a checklist rather than a verdict.

The score is a heuristic, not a guarantee: it counts how many hardening features are enabled, not whether the service is actually exploitable. A score of 9 on a service that only listens on localhost is less alarming than a 5 on an internet-facing daemon with a history of CVEs. Use the number to track improvement over time and catch regressions when a package upgrade rewrites your unit files.

Filesystem Sandboxing

[Service]
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/nginx /var/log/nginx
  • ProtectSystem=strict remounts the whole filesystem read-only except the paths you list in ReadWritePaths.
  • ProtectHome=true makes /home, /root, and /run/user inaccessible to the service.
  • PrivateTmp=true gives the service its own /tmp, so it can neither read nor write other processes’ temp files.

Dropping Capabilities

[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_RAW
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true

A web server typically only needs to bind low ports (CAP_NET_BIND_SERVICE) and, for ping or ICMP checks, CAP_NET_RAW. Everything else gets dropped from the bounding set — even a root-level exploit inside the service cannot regain capabilities that were never granted.

Capabilities are the Linux answer to “run as root but not really”: instead of granting the service full root, you grant it the specific privileges it needs. The bounding set is the ceiling for the process and all its children, and AmbientCapabilities (with NoNewPrivileges=true) actually hands the permitted capability to the running process. If a service breaks after tightening the set, run journalctl -u example.service and look for “Operation not permitted” — that tells you exactly which capability it still needs.

  • Start from the defaults with CapabilityBoundingSet= (empty) and add back only what the service reports missing.
  • Keep NoNewPrivileges=true whenever possible — it blocks setuid binaries from escalating.
  • Re-check with systemd-analyze security after each change; the score drops as the set shrinks.

Resource Limits for Runaway Processes

[Service]
MemoryMax=512M
MemoryHigh=384M
CPUQuota=75%
TasksMax=256
Restart=on-failure
  • MemoryMax is a hard cap — the kernel OOM-kills the service rather than letting it exhaust the VPS.
  • MemoryHigh applies memory pressure before the hard limit, throttling a slow leak before it becomes fatal.
  • CPUQuota=75% caps CPU at 75% of one core, and TasksMax limits threads and forked children.
  • Restart=on-failure brings the service back automatically without masking the underlying issue.

Network and Device Isolation

[Service]
PrivateNetwork=true
RestrictAddressFamilies=AF_INET AF_INET6
DevicePolicy=closed
DeviceAllow=/dev/null rw /dev/random r /dev/urandom r

PrivateNetwork=true gives the service a network namespace with only a loopback interface — ideal for workers that only talk to local sockets. DevicePolicy=closed denies access to all device nodes except the ones you explicitly allow.

A Hardened Unit End to End

[Unit]
Description=Hardened example service
After=network.target

[Service]
ExecStart=/usr/local/bin/example --config /etc/example.conf
User=example
Group=example
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
MemoryMax=512M
TasksMax=256
Restart=on-failure

[Install]
WantedBy=multi-user.target

Run the service as an unprivileged user, combine the sandbox directives, and keep the resource limits explicit. Every line here is optional — add what your service tolerates and drop what breaks it.

Two details matter when you deploy this unit. First, PrivateDevices=true (used above) implies DevicePolicy=closed plus allowlists for /dev/null, /dev/random, and /dev/urandom, so you usually do not need both — pick one style and stay consistent. Second, keep the [Install] section intact: dropping it silently disables systemctl enable, and the service will not survive a reboot.

Also note that hardening directives are inheritable: any service the unit spawns (workers, subprocesses, CGI handlers) runs under the same restrictions. That is what you want for security, but it means the sandbox must fit the whole process tree, not just the parent — test with a real workload.

Verifying the Hardening

systemctl daemon-reload && systemctl restart example
systemd-analyze security example
# → Overall exposure level: 2.1 SAFE
systemctl status example   # confirm it is running under the new limits

Apply directives one at a time and re-run the audit after each change; breakage shows up immediately in systemctl status with a clear error line, and you can roll back by deleting the directive and reloading.

Put the hardening in a drop-in instead of editing the vendor unit: create /etc/systemd/system/example.service.d/hardening.conf with the [Service] overrides. Package upgrades then merge your settings instead of overwriting them, and you can see at a glance which directives are site-specific. Run systemctl cat example.service to confirm the merged unit looks the way you expect.

  • Test the hardened unit under real traffic for at least one release cycle before rolling it out everywhere.
  • Document the directives in your runbook — the next person who debugs a “permission denied” will thank you.
  • Re-run systemd-analyze security after every package upgrade; some packages reset unit files on install.

A hardened unit costs nothing in throughput but removes entire classes of privilege-escalation and resource-exhaustion attacks. When you are choosing the node to run it on, compare plans side by side on our comparison table to get predictable per-core CPU and enough RAM for your limits.

InterServer VPS plans give you full root access and dedicated vCPU allocation, so hardened services get stable, predictable resources. Check InterServer VPS plans and pricing to spin up a locked-down node today.

Leave a Reply