Most VPS security guides stop at SSH keys and a firewall. The service layer is where attackers actually get in once they find a vulnerable process, and systemd gives you a set of sandboxing options that can contain a compromise before it becomes root access. This article covers the three highest-impact hardening directives — ProtectSystem, PrivateTmp, and capability dropping — plus the supporting options worth enabling, with working unit-file examples for a typical web stack.
Why Harden Systemd Services on a VPS?
A VPS is a single tenancy kernel, so there is no hypervisor boundary between your services the way there is between containers. If nginx or PHP-FPM gets exploited, the attacker inherits that service’s privileges. systemd’s sandboxing directives turn those privileges into a minimal, read-only, capability-limited surface. On a 1-2 vCPU VPS the overhead is effectively zero — these are kernel-level restrictions, not another agent consuming RAM. The trade-off is configuration effort and occasional breakage, which is why each option below includes a verification step. For a baseline of what a securely provisioned VPS should look like, the security and compliance details on our main site list the checks we apply before any server goes live.
ProtectSystem: Make the Filesystem Read-Only
ProtectSystem= remounts parts of the filesystem read-only for the service. The levels are:
ProtectSystem=yes— makes/usr,/boot, and/etcread-only.ProtectSystem=full— adds/usr,/boot,/etc,/lib,/bin,/sbinand the whole root filesystem, keeping only/dev,/proc,/sysand/homewritable.ProtectSystem=strict— makes the entire filesystem hierarchy read-only except for explicitly whitelisted paths viaReadWritePaths=.
For a web service, strict with an explicit writable list is the right target. A PHP-FPM pool that only needs to write to its session directory and log socket looks like this:
# /etc/systemd/system/php8.3-fpm.service.d/hardening.conf
[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/php/sessions /run/php
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
Reload and verify that the service still writes where it needs to, then confirm the restriction is active:
systemctl daemon-reload
systemctl restart php8.3-fpm
systemctl show php8.3-fpm -p ProtectSystem -p ReadWritePaths
# expected: ProtectSystem=strict
The most common breakage with strict is an application that writes to an unexpected path — a cache directory, a temp file, or its own chdir directory. The fix is always to add that path to ReadWritePaths=, never to relax the whole setting. If an attacker later exploits the process, they can only write inside those explicitly listed directories, and they cannot modify binaries, libraries, or system configuration.
PrivateTmp: Isolate /tmp and /var/tmp
Shared /tmp is a classic cross-service attack surface: a compromised process can plant a symlink or a malicious file that another service with more privileges later executes. PrivateTmp=true gives the service its own private /tmp and /var/tmp namespace, mounted via PrivateDevices-style file system namespaces. The service sees only its own temporary directory; nothing it writes there is visible to other services or to unprivileged local users.
[Service]
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
PrivateDevices=true pairs well here: it gives the service a minimal /dev with only null, zero, random, and urandom, blocking access to raw disks and hardware. NoNewPrivileges=true prevents the process and its children from gaining new privileges via setuid binaries — a cheap, universally safe addition. Note that PrivateTmp is per-service; if two services need to share a temp file, use an explicit directory under ReadWritePaths= instead of relying on /tmp. The performance impact on a VPS is negligible because these are namespace and mount operations performed once at service start.
Drop Capabilities with CapabilityBoundingSet
Capabilities are the granular privileges Linux grants to processes that would otherwise need full root. A service started as root holds a bounding set of all capabilities it may ever gain; you should trim that set to what the service actually uses. For nginx, which needs to bind to port 80/443, read config, and manage its worker processes, a hardened set looks like:
# /etc/systemd/system/nginx.service.d/hardening.conf
[Service]
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_RAW CAP_DAC_OVERRIDE CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
What this does in practice:
CAP_NET_BIND_SERVICE— bind to privileged ports below 1024.CAP_NET_RAW— needed if nginx uses therealipmodule’s raw socket checks; drop it if your setup does not require it.CAP_DAC_OVERRIDE— read files the service user may not own; often droppable if you fix file ownership instead.CAP_SETUID/CAP_SETGID— allow the master process to drop privileges for workers.
Verify the effective set after restart:
systemctl restart nginx
systemctl show nginx -p CapabilityBoundingSet
# or from the process itself:
grep Cap /proc/$(pgrep -o nginx)/status
capsh --decode=$(grep CapEff /proc/$(pgrep -o nginx)/status | awk '{print $2}')
The goal is not a minimal theoretical set; it is removing the capabilities that a real exploit would use to escalate — CAP_SYS_ADMIN, CAP_SYS_PTRACE, CAP_SYS_MODULE, and CAP_DAC_READ_SEARCH should never appear in a web service’s bounding set. If the service fails to start after tightening, check journalctl -u <service> -n 50 for permission errors and re-add only the specific capability the error names. A full walkthrough of applying these units across nginx, PHP-FPM, and a database is beyond this article, but the provider comparison table on our main site can help you pick a VPS plan with enough headroom that these hardening layers never cost you performance.
Supporting Options Worth Enabling
These four directives cost almost nothing and close common holes:
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX— blocks raw packet sockets and netlink, stopping a class of network-based privilege escalation.MemoryDenyWriteExecute=true— preventsmmapwith both write and execute permissions; breaks JIT runtimes like V8, so test carefully before enabling on Node.js services.RestrictRealtime=true— blocks real-time scheduling policies that could be abused for CPU starvation.SystemCallFilter=@system-service— an allowlist of syscalls; start with this and add exceptions only when the application demonstrably needs them.
You can audit all of a service’s effective restrictions in one command:
systemd-analyze security nginx
# produces a 0-10 exposure score per service and lists which directives are unset
systemd-analyze security is the fastest way to find gaps: it prints every hardening option and flags the ones you left at their insecure defaults. Run it on every service exposed to the network, and treat anything scoring above 5 as a candidate for the directives above.
Testing Your Hardened Configuration
Before declaring victory, prove the sandbox works. As the service user (or from a shell that drops to it), attempt the actions the directives should block:
# should fail under ProtectSystem=strict
sudo -u www-data touch /etc/test-write
# should fail under PrivateTmp — the file is invisible to other users
ls /tmp/php-session-test
# should fail under the trimmed capability set
capsh --drop=all -- -c 'setcap cap_sys_admin+ep /bin/true'
Each failed attempt confirms the boundary is real, not just declared. Then re-run your normal health checks — site loads, sessions persist, logs rotate — to confirm the service still functions. Hardening that breaks the application is a denial of service; the verification loop is what keeps the two in balance. If you are moving a hardened workload between providers, our FAQ covers what to re-check after migration, since kernel versions differ and a directive that parses on Ubuntu 24.04 may need adjustment elsewhere.
Summary
ProtectSystem=strictwith explicitReadWritePaths=makes most of the filesystem read-only for the service.PrivateTmp=trueisolates temporary files per service and kills the shared-/tmpattack surface.CapabilityBoundingSet=trims what a compromised process can do, andNoNewPrivileges=trueprevents escalation via setuid.- Audit with
systemd-analyze securityand verify each restriction with a real failed attempt.
These three directives — ProtectSystem, PrivateTmp, and capability dropping — give you most of the isolation benefit of containers with none of the orchestration overhead, directly on the host kernel of your VPS. Apply them to every network-facing service, verify each one, and the blast radius of any single compromise shrinks dramatically.

Leave a Reply
You must be logged in to post a comment.