systemd Service Hardening on a VPS: Sandboxing Self-Hosted Apps Without Breaking Them
Lock down self-hosted services with systemd sandboxing: ProtectSystem, capabilities, syscall filters and IP rules — plus how to test them without downtime.
If you run self-hosted apps on a VPS — Vaultwarden, a Matrix homeserver, a trading bot, a Go binary you wrote yourself — the single cheapest security upgrade available to you is a dozen lines in a systemd unit file. systemd's sandboxing directives let you strip a service down to the files, devices, capabilities and syscalls it actually needs, so a remote code execution bug in that app stops at the edge of its own sandbox instead of becoming root on your server.
This is not theoretical hardening. It costs nothing in RAM, needs no containers, and works on Debian, Ubuntu and AlmaLinux out of the box. Below is what each directive actually blocks, a baseline you can paste in, and — importantly — how to apply it without silently breaking the service.
The short answer: what gives you the most protection
If you only do five things to a unit file, do these:
- Don't run it as root.
User=a dedicated account, orDynamicUser=yesfor stateless services. NoNewPrivileges=yes— blocks setuid binaries and file capabilities from escalating privileges inside the service.ProtectSystem=strict+ explicitReadWritePaths=— the entire filesystem becomes read-only except the paths you name.- Drop capabilities with
CapabilityBoundingSet=(empty, unless you genuinely need one). SystemCallFilter=@system-service— blocks kernel syscall families that normal daemons never use (module loading, raw I/O ports, kexec, etc.).
Everything else is refinement. Those five turn "attacker owns the box" into "attacker owns one unprivileged process with a read-only root filesystem."
Why this matters more on a small privacy-focused VPS
On a single €4.99–€19.99/mo server, workload separation is usually limited. One VPS often runs a reverse proxy, a database, and two or three application services side by side. If one of those apps is compromised and runs as root, everything else on the disk is readable — including WireGuard private keys, SSH authorized_keys, backup credentials and API tokens.
Sandboxing each unit gives you a cheap approximation of the isolation you'd get from separate servers. It's not as strong as actually splitting workloads, but it's free and immediate. It also complements, rather than replaces, network-layer controls: remember that published Docker ports can bypass your nftables rules entirely, so if your app runs in a container, fix that first.
One more reason this matters on a no-KYC VPS specifically: nobody else is watching your server. There's no managed-security team reviewing your unit files. Unmanaged root access means unmanaged responsibility — the sandbox you write is the sandbox you get.
Use drop-ins, never edit vendor unit files
Packages overwrite /lib/systemd/system/*.service on upgrade. Always override with a drop-in:
systemctl edit myapp.service
That creates /etc/systemd/system/myapp.service.d/override.conf, which survives package updates. Then:
systemctl daemon-reload
systemctl restart myapp
A practical baseline unit
Start here and loosen only what breaks:
[Service]
User=myapp
Group=myapp
NoNewPrivileges=yes
# Filesystem
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
StateDirectory=myapp
ReadWritePaths=/var/lib/myapp
# Kernel and host
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectProc=invisible
ProcSubset=pid
PrivateDevices=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
# Syscalls and networking
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Resource containment
MemoryMax=512M
TasksMax=256
What each group actually does
ProtectSystem=strict mounts /usr, /boot and /etc read-only — in fact the whole filesystem hierarchy — so an attacker can't drop a cron job in /etc/cron.d or replace a binary. Anything the app legitimately writes must be listed in ReadWritePaths=, or managed with StateDirectory= (which creates and chowns /var/lib/<name> for you).
ProtectHome=yes makes /home, /root and /run/user inaccessible. A compromised web app can no longer read /root/.ssh/ or your shell history.
PrivateTmp=yes gives the service its own /tmp namespace. This kills a whole class of symlink and predictable-tempfile attacks between services sharing a box.
CapabilityBoundingSet= (empty) removes every Linux capability, including CAP_NET_RAW, CAP_SYS_ADMIN and CAP_DAC_OVERRIDE. If your service must bind port 80 or 443 as a non-root user, you have two options: grant exactly CAP_NET_BIND_SERVICE in both CapabilityBoundingSet= and AmbientCapabilities=, or set net.ipv4.ip_unprivileged_port_start=80 via sysctl and keep capabilities empty. The second is usually cleaner.
SystemCallFilter=@system-service applies a seccomp allowlist of syscall groups appropriate for daemons. Setting SystemCallErrorNumber=EPERM makes denied calls return an error instead of killing the process with SIGSYS, which produces far more debuggable failures.
RestrictAddressFamilies= stops the process from opening raw, netlink or packet sockets. If a service never talks to the network at all, use PrivateNetwork=yes — that's the strongest network containment systemd offers.
MemoryMax= and TasksMax= are stability, not security — but on a 1–4 GB VPS they prevent one runaway service from triggering the OOM killer against your database. If you're working with a small plan, pair this with proper swap, zram and OOM tuning.
Per-service IP filtering with IPAddressAllow
systemd can enforce cgroup-level BPF firewalling per service — independent of nftables. This is useful for services that should only ever talk to localhost or one upstream:
IPAddressDeny=any
IPAddressAllow=localhost
IPAddressAllow=10.8.0.0/24
It's a blunt instrument (no port matching), but as a defence-in-depth layer it's excellent: a compromised internal service literally cannot phone out to an attacker's IP, even if your egress firewall rules are loose. Note that IPAddressDeny= on a unit with DynamicUser or inside containers can behave unexpectedly — test with curl from inside the service's context before trusting it.
Test the sandbox before you trust it
systemd ships a scoring tool:
systemd-analyze security myapp.service
It lists every directive, whether it's set, and an exposure score from 0 (locked down) to 10 (unrestricted). The score is a heuristic, not a verdict — a 2.5 on a service that also stores plaintext API keys isn't "safe." Use it to spot what you forgot, not as a grade.
To find which syscalls your app actually needs before enforcing a filter:
SystemCallLog=@privileged @mount
That logs matching syscalls to the journal without blocking them. Watch journalctl -u myapp -f under real load, then convert the findings into your filter.
Three practical test habits:
- Restart the service and exercise every feature, not just the homepage. File uploads, email sending, scheduled jobs and backup hooks are where
ProtectSystem=strictbites. - Check the journal after a restart: permission errors from sandboxing look like plain
EACCES/EPERMand are easy to misread as app bugs. - Keep a second SSH session open while you experiment. The same rule that applies when you're hardening SSH itself applies here: never lock yourself out of your only way in.
A privacy-friendly side benefit: deploying a throwaway test VPS is cheap when accounts need no email or ID and billing is per month in crypto. Spinning up a second Iron 1 at €4.99/mo to rehearse a hardened unit file is a reasonable alternative to breaking production — deploy one in under a minute and destroy it when you're done.
Common mistakes
Copying a hardened unit from a blog without checking paths. ReadWritePaths= is service-specific. A Vaultwarden unit's paths won't match a Matrix homeserver's.
Forgetting that databases need write access to more than their data directory. PostgreSQL and MariaDB touch socket directories under /run and /var/run too — use RuntimeDirectory=.
Setting MemoryDenyWriteExecute=yes on anything with a JIT. Node.js, modern Python with certain extensions, Java and browser engines all allocate writable-executable memory. This directive will crash them. It's great for compiled Go, Rust and C daemons; it's wrong for most interpreted stacks.
Assuming the sandbox protects the app's own data. It doesn't. If an attacker gets code execution inside a Nextcloud or Vaultwarden process, that process can already read the database it's allowed to read. Sandboxing limits lateral movement, not the blast radius inside the service itself.
Hardening units while leaving monitoring agents running as root. Your least-protected privileged process sets your actual security level. Self-hosted monitoring stacks deserve the same treatment.
Honest limits
systemd sandboxing is a namespace-and-seccomp fence enforced by the kernel. That means:
- A kernel vulnerability that escapes seccomp or user namespaces defeats it. Keep the kernel patched; sandboxing is not a substitute for updates.
- It does nothing against application-layer attacks — SQL injection, stolen API tokens, weak admin passwords.
- It is invisible to the hypervisor layer. Any host with access to your VM's disk and memory sees past every guest-side control you configure, which is a separate question covered in what your VPS provider can actually see.
- It doesn't encrypt anything or hide what you're running. Traffic patterns and open ports look identical from outside.
What it does, reliably, is make the gap between "one app got popped" and "the whole server got popped" much harder to cross. On a single-server setup that's often the difference that matters.
FAQ
Does this replace Docker or Podman? For a single service, often yes — systemd sandboxing covers much of what container isolation gives you, without a daemon, an overlay filesystem or the firewall surprises containers bring. Containers still win for dependency packaging and reproducibility.
Will it slow my service down? Seccomp filtering adds a tiny per-syscall cost that's unmeasurable for typical web workloads. Namespace mounts are a one-time setup cost at start.
Does it work on AlmaLinux/RHEL with SELinux enabled? Yes, and they're complementary — SELinux enforces label-based policy, systemd enforces namespace and syscall restrictions. Running both is fine.
What's a good exposure score to aim for? Under 4.0 is achievable for most network daemons. Don't chase 0.0 by setting directives you haven't tested.
Get started
Pick your most exposed service — the one listening on a public port — and write a drop-in for it today. Run systemd-analyze security before and after, exercise the app properly, and move to the next unit once it's stable.
If you need a disposable server to test hardened units against, or a clean EU-hosted VPS with full root and no identity attached to it, IronBalkans runs KVM instances in Bucharest from €4.99/mo, paid in Monero, Bitcoin or Litecoin, with no email or ID at signup. Compare the plans and deploy in under 60 seconds.
