The most surprising thing about systemd service security is how much power you have to isolate services without touching the application code itself.
Let’s look at a hypothetical web server, my-web-app.service. By default, it might run with broad permissions.
[Unit]
Description=My Awesome Web App
[Service]
ExecStart=/usr/local/bin/my-web-app
Restart=on-failure
[Install]
WantedBy=multi-user.target
This is fine for a simple setup, but if my-web-app gets compromised, the attacker has the privileges of the user running it, and potentially access to anything that user can access. We can do much better.
The core idea is to define a [Service] section in the .service file that precisely dictates what the service can and cannot do. Think of it like a mini-firewall for your process.
Let’s start by giving it a dedicated, unprivileged user. This is fundamental.
[Service]
User=mywebappuser
Group=mywebappgroup
ExecStart=/usr/local/bin/my-web-app
Restart=on-failure
Now, create that user and group:
sudo groupadd mywebappgroup
sudo useradd -r -s /sbin/nologin -g mywebappgroup mywebappuser
The -r creates a system user, -s /sbin/nologin prevents interactive logins, and -g assigns it to our new group. This user now has a minimal home directory and very few system privileges by default.
Next, let’s restrict its filesystem access. Most web apps only need to read their configuration and static files, and write to specific log or data directories. We can use ProtectSystem, ProtectHome, and ReadWriteDirectories.
[Service]
User=mywebappuser
Group=mywebappgroup
ExecStart=/usr/local/bin/my-web-app
Restart=on-failure
# System-wide read-only filesystem
ProtectSystem=full
# Home directories read-only
ProtectHome=yes
# Explicitly allow writing to log and data dirs
ReadWriteDirectories=/var/log/my-web-app /var/lib/my-web-app
ProtectSystem=full makes /usr, /boot, /etc, and /run read-only. ProtectHome=yes makes all user home directories (/home, /root, /run/user/*) inaccessible, even for reading. ReadWriteDirectories is crucial: it only grants write access to the specified paths, and makes everything else read-only. Ensure these directories exist and are owned by the mywebappuser and mywebappgroup:
sudo mkdir -p /var/log/my-web-app /var/lib/my-web-app
sudo chown mywebappuser:mywebappgroup /var/log/my-web-app /var/lib/my-web-app
ProtectKernelTunables=yes prevents modification of /proc/sys parameters, and ProtectKernelLogs=yes prevents access to kernel logs.
[Service]
User=mywebappuser
Group=mywebappgroup
ExecStart=/usr/local/bin/my-web-app
Restart=on-failure
ProtectSystem=full
ProtectHome=yes
ReadWriteDirectories=/var/log/my-web-app /var/lib/my-web-app
ProtectKernelTunables=yes
ProtectKernelLogs=yes
NoNewPrivileges=yes is a powerful one. It prevents the process from gaining any new privileges, even if it could exploit a vulnerability to do so (like setuid binaries).
[Service]
User=mywebappuser
Group=mywebappgroup
ExecStart=/usr/local/bin/my-web-app
Restart=on-failure
ProtectSystem=full
ProtectHome=yes
ReadWriteDirectories=/var/log/my-web-app /var/lib/my-web-app
ProtectKernelTunables=yes
ProtectKernelLogs=yes
NoNewPrivileges=yes
For network services, restricting network access is paramount. PrivateNetwork=yes gives the service its own isolated network namespace, meaning it can only see a loopback interface (lo) and cannot reach any other network interfaces or services on the host. If your web app needs to bind to a specific port (e.g., 80 or 443), you’ll need to allow that before enabling PrivateNetwork.
[Service]
User=mywebappuser
Group=mywebappgroup
ExecStart=/usr/local/bin/my-web-app
Restart=on-failure
ProtectSystem=full
ProtectHome=yes
ReadWriteDirectories=/var/log/my-web-app /var/lib/my-web-app
ProtectKernelTunables=yes
ProtectKernelLogs=yes
NoNewPrivileges=yes
# Allow binding to port 80, but nothing else network-wise
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
PrivateNetwork=yes
CapabilityBoundingSet and AmbientCapabilities are key here. CAP_NET_BIND_SERVICE is the capability required to bind to privileged ports (those below 1024). By bounding the capabilities and setting the ambient capabilities to only CAP_NET_BIND_SERVICE, we drastically limit what the process can do. PrivateNetwork=yes then ensures that even with this capability, it can only bind to the loopback interface, or if you want it to be reachable from outside, you’d omit PrivateNetwork=yes and use host firewall rules (like iptables or firewalld) to control access.
After making these changes, reload systemd and restart your service:
sudo systemctl daemon-reload
sudo systemctl restart my-web-app.service
You can verify the current settings for your service with systemctl status my-web-app.service.
The systemd unit file itself can be placed in /etc/systemd/system/my-web-app.service.
One of the most powerful, yet often overlooked, hardening options is SystemCallFilter. This directive allows you to specify a whitelist of allowed system calls. If a service attempts to make a system call not on the list, the kernel will terminate the process. This provides a very granular layer of defense against exploits that rely on unexpected system calls. For example, to restrict it to basic I/O and process management, you might have a very long list of allowed calls, denying everything else by default.
With these changes, a compromised my-web-app process is severely hobbled. It runs as an unprivileged user, cannot access sensitive system files or user data, cannot gain new privileges, and is isolated from the network.
The next problem you’ll likely encounter is debugging. When a service fails under these strict restrictions, its logs might be less informative, and its inability to perform certain operations can be confusing.