Systemd Sandboxing for Self-Hosted Services
Harden Linux services with systemd sandboxing. Limit filesystem writes, drop capabilities, and test restrictions without chasing a perfect security score.
Your self-hosted service probably needs to write to one directory. Without systemd sandboxing, it might be able to write to everything its account owns, inspect unrelated files, and launch programs you never intended it to run.
I would rather narrow those permissions before something breaks than explain afterward why a tiny dashboard could modify half the server. A firewall controls connections. It does not decide what an exploited process can do after a connection succeeds.
The short version: run native services as unprivileged accounts, make their filesystem view read-only, explicitly provide writable state, and test the application after each restriction. This guide is for system-level systemd services on Linux, not a drop-in hardening recipe for Docker itself.
Start with a harmless service, not your password manager
A restriction is useful only if you understand what it blocks. Start with an isolated demonstration, then transfer the settings to a real service one at a time.
You need a Linux machine booted with systemd, sudo access, and Python 3 at /usr/bin/python3. The example serves a static file on loopback port 8787. It has no authentication and must not be exposed publicly.
Check the prerequisites first:
systemd --version
command -v python3
ss -ltn 'sport = :8787'
If the port is already occupied, choose another one throughout the example. If Python lives somewhere else, adjust ExecStart accordingly.
Create an account dedicated to this demonstration:
sudo useradd --system --user-group --home-dir /nonexistent \
--shell /usr/sbin/nologin sandbox-demo
If that account already exists, inspect it rather than treating an error as permission to reuse an unrelated identity. A separate UID is the baseline here, not an optional detail.
Create /etc/systemd/system/sandbox-demo.service with this content:
[Unit]
Description=Loopback-only systemd sandbox demonstration
After=network.target
[Service]
Type=simple
User=sandbox-demo
Group=sandbox-demo
StateDirectory=sandbox-demo
StateDirectoryMode=0750
WorkingDirectory=/var/lib/sandbox-demo
ExecStart=/usr/bin/python3 -m http.server 8787 --bind 127.0.0.1 --directory /var/lib/sandbox-demo
Restart=on-failure
RestartSec=5
UMask=0027
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
There is deliberately no installation section. We will start this manually, not enable a toy HTTP server at boot.
What the filesystem restrictions actually do
ProtectSystem=strict gives the service a read-only view of most of the filesystem. It does not make those files unreadable, and it does not protect the host from every possible kernel vulnerability.
StateDirectory=sandbox-demo asks systemd to create /var/lib/sandbox-demo with ownership appropriate for the service account. Systemd also makes that managed state directory writable despite ProtectSystem=strict. That combination is much less fragile than manually opening up large parent directories.
ProtectHome=yes hides /home, /root, and /run/user from the service. This is why the demonstration does not put its application files in a login user’s home directory.
PrivateTmp=yes gives the service private temporary directories. A file created in its /tmp is not simply the same file another service sees in /tmp. Do not use the shared temporary directory as an undocumented interface between processes and then wonder why isolation breaks it.
For real applications, keep code and static configuration read-only. Give databases, uploads, and other persistent state dedicated writable locations. If the application cannot use a systemd-managed directory, a narrowly scoped ReadWritePaths=/specific/path can provide an exception, but it neither creates the directory nor overrides normal Unix permissions.
I would not use ReadWritePaths=/var just to make an error disappear. That is the filesystem equivalent of replacing a locked door with a polite sign.
Remove privileges the application never needed
NoNewPrivileges=yes prevents the service and its descendants from gaining new privileges through mechanisms such as set-user-ID executables. It does not remove privileges the process already has, which is why we also use an unprivileged account.
The empty CapabilityBoundingSet= removes Linux capabilities from the service’s bounding set. The empty AmbientCapabilities= ensures we do not intentionally pass ambient capabilities to the process. Listening on port 8787 requires no privileged-port capability.
PrivateDevices=yes provides a restricted view of devices. That fits a static HTTP demonstration, but it can break media transcoding, USB hardware access, or applications that legitimately need device nodes.
The kernel and control-group protections stop several classes of host configuration changes. RestrictSUIDSGID=yes, RestrictRealtime=yes, and LockPersonality=yes remove other functionality this server does not need.
These are layers, not a promise that arbitrary compromised software is safe. The account can still read files permitted by its credentials and the namespace view. Never store unrelated secrets in world-readable files and expect this unit to repair that decision.
Address-family restrictions are not a firewall
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 permits the socket families commonly needed by a small web application. It does not restrict the process to a particular destination, block outbound internet access, or make a public listener private.
The --bind 127.0.0.1 argument is what makes this example listen only on IPv4 loopback. A reverse proxy on the same host could reach it, but a remote browser cannot connect directly.
For a real service, combine application binding, firewall rules, and authentication according to your deployment. See our VPS hardening guide for host-level controls and restricted SSH tunnels for private administrative access.
Keep VPN products in their proper category, too. A commercial outbound VPN is not a substitute for this sandbox, nor does a subscription automatically provide private inbound access to your server. For a separate outbound-privacy requirement, NordVPN is an affiliate option:
🚀NordVPN
An optional VPN for outbound privacy, not a replacement for service isolation or authentication.
Affiliate link — we may earn a commission at no extra cost to you.
Validate the unit, then test the running process
Before starting anything, check the unit file:
sudo systemd-analyze verify /etc/systemd/system/sandbox-demo.service
sudo systemctl daemon-reload
sudo systemctl start sandbox-demo.service
sudo systemctl status sandbox-demo.service --no-pager
A successful syntax check is not a successful deployment. Missing executables, account problems, port conflicts, and unavailable namespace features can still prevent startup.
Once systemd has created the state directory, add a harmless file:
printf 'Sandbox demo is serving its state directory.\n' \
| sudo tee /var/lib/sandbox-demo/index.html >/dev/null
sudo chown sandbox-demo:sandbox-demo /var/lib/sandbox-demo/index.html
sudo chmod 0640 /var/lib/sandbox-demo/index.html
curl --fail http://127.0.0.1:8787/
sudo journalctl -u sandbox-demo.service -n 30 --no-pager
The response should contain the sentence you wrote. That is an expected result for your machine, not a claim that this article deployed the service on your server.
You can also inspect the process’s mount namespace. On hosts with nsenter, try this deliberate write to a throwaway path:
pid=$(systemctl show sandbox-demo.service --property=MainPID --value)
test "$pid" -gt 0 && sudo nsenter --target "$pid" --mount \
/usr/bin/touch /etc/sandbox-demo-should-not-exist
With the mount restrictions active, the attempted write should fail with a read-only-filesystem error. Running through sudo nsenter intentionally checks the filesystem namespace, not the service user’s complete credentials or every sandbox setting.
Do not use a real configuration filename for a negative test. If the write unexpectedly succeeds, remove the throwaway file and investigate before trusting the isolation.
Use the security score as a checklist
Systemd can report on a unit’s exposure:
sudo systemd-analyze security sandbox-demo.service
Read the individual findings. The score evaluates configured restrictions; it does not audit your Python code, verify authentication, inspect dependency vulnerabilities, or prove your backups can be restored.
I would keep a working service with a documented exception before chasing a prettier number. A web application needs networking. A transcoder may need a GPU. A backup job needs access to its source and destination.
For example, MemoryDenyWriteExecute=yes can interfere with just-in-time runtimes, and syscall filters can break applications in less obvious ways. Neither belongs in a universal copy-and-paste template without workload-specific testing.
Apply the pattern to an existing service
Do not overwrite a vendor unit. Create an override instead:
sudo systemctl edit your-app.service
Start with a small change:
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
Restart during a maintenance window, then exercise login, uploads, background jobs, exports, and integrations. A green process status does not mean every feature still works.
Next, inventory writable paths and plan ProtectSystem=strict with the necessary state directories or path exceptions. Check whether the application writes next to its executable, reads credentials from a home directory, invokes privileged helpers, or depends on another process’s temporary files.
Keep a copy of the exact override before changing it. If a new restriction breaks the workload, remove that specific setting, reload systemd, and restart. Avoid blindly running systemctl revert on a production unit: it can remove other local overrides you meant to keep.
Our systemd timer guide covers scheduling. The same least-privilege thinking applies to the service a timer starts, but a scheduled backup needs different filesystem permissions from this web demo.
Limits worth remembering
These examples target system services on a normal Linux host. User services and restricted containers may lack the namespace privileges required for some settings. Inspect startup errors rather than silently deleting the hardening block.
Some protections depend on kernel features, systemd version, and execution environment. Consult your host’s man systemd.exec and the upstream execution-environment reference. The systemd-analyze reference explains the security assessment.
Do not apply this template directly to docker.service. A container engine has very different privilege requirements, and access to its socket can defeat the isolation you are trying to build. Native service sandboxing and container security are related problems, not interchangeable configurations.
Clean up and pick one real service
Stop the demonstration when you are finished:
sudo systemctl stop sandbox-demo.service
sudo rm /etc/systemd/system/sandbox-demo.service
sudo systemctl daemon-reload
The state directory and account remain intentionally. Inspect their contents and ownership before deciding whether to remove them; cleanup should not become an excuse for a broad recursive delete command.
Your next step is not to harden every unit tonight. Pick one small native service, list the files it must read and write, and add one restriction you can actually verify. That is a better security improvement than a perfect score nobody has tested.
Stay in the loop 📬
Get self-hosting tutorials, tool reviews, and infrastructure tips delivered to your inbox. No spam, unsubscribe anytime.
Join 0 self-hosters. Free forever.