DevOpsil
Linux
92%
Needs Review

Systemd Service Hardening: Sandbox Your Daemons

Raafay AsifRaafay Asif5 min read

Your Daemon Is Running as Root and You Don't Know What It Can Touch

Most default systemd unit files shipped by packages are functional, not secure. They run with full filesystem access, can load kernel modules, can ptrace other processes, and often run as root. A vulnerability in that service is a full system compromise.

The good news: systemd has an extensive set of security directives that let you sandbox services without containers, without AppArmor profiles (though those help too), and without modifying the application code. You're working at the unit file level.

Let me show you what a hardened unit file looks like and how to get there systematically.

Start With the Security Score

Before changing anything, run systemd-analyze security to see where things stand:

# Check all running services
systemd-analyze security

# Drill into a specific service
systemd-analyze security nginx.service

Sample output for an unhardened nginx:

  NAME                                                        DESCRIPTION                              EXPOSURE
✗ PrivateNetwork=                                             Service has access to the host network    0.5
✗ User=/DynamicUser=                                         Service runs as root user                 0.4
✗ NoNewPrivileges=                                           Service may elevate privileges            0.2
✗ ProtectHome=                                               Service has full access to home dirs      0.2
✗ ProtectSystem=                                             Service has full access to OS file system 0.2
...
→ Overall exposure level for nginx.service: 9.2 UNSAFE

The score ranges from 0 (maximally secure) to 10 (completely exposed). Anything above 7 deserves attention on a production server.

The Hardening Directives: A Practical Reference

Filesystem Isolation

[Service]
# Make / read-only except for explicit write paths
ProtectSystem=strict

# Hide /home, /root, /run/user
ProtectHome=true

# The directories this service needs write access to (overrides ProtectSystem=strict)
ReadWritePaths=/var/lib/myapp /var/log/myapp

# Only expose specific paths as readable
ReadOnlyPaths=/etc/myapp

# Prevent the service from following /proc or /sys paths for info it shouldn't have
ProtectProc=invisible
ProcSubset=pid

User and Privilege Isolation

[Service]
# Run as a specific non-root user
User=myapp
Group=myapp

# Alternatively — let systemd create an ephemeral user per invocation
# DynamicUser=true

# Prevent setuid, setgid, privilege escalation
NoNewPrivileges=true

# Remove all capabilities and only add what's needed
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

If your service needs to bind port 80 or 443 but shouldn't run as root, CAP_NET_BIND_SERVICE is all it needs. That's far better than User=root.

Kernel and System Call Filtering

[Service]
# Prevent loading kernel modules
ProtectKernelModules=true

# Prevent writing to kernel variables (/proc/sys, /sys)
ProtectKernelTunables=true

# Prevent tampering with kernel log ring buffer
ProtectKernelLogs=true

# Prevent changing system clock
ProtectClock=true

# Restrict to a safe subset of syscalls (use 'native' for compiled binaries)
SystemCallFilter=@system-service
SystemCallArchitectures=native

Systemd ships predefined syscall groups you can reference with @:

GroupIncludes
@system-serviceTypical server daemon syscalls
@network-ioSocket operations
@file-systemFile open/read/write/stat
@processfork, exec, wait
@basic-ioread, write, close

To see what's in a group: systemd-analyze syscall-filter @system-service

Network and IPC Isolation

[Service]
# If the service doesn't need internet access at all
PrivateNetwork=true

# If it only needs localhost
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

# Prevent binding to abstract Unix sockets (common attack vector)
RestrictAddressFamilies=~AF_UNIX

# Prevent ptrace of other processes
RestrictNamespaces=true

# Isolate from other services' temporary files
PrivateTmp=true

A Complete Hardened Unit File Example

Here's a real-world hardened unit file for a Go-based HTTP API:

[Unit]
Description=My Go API Service
After=network.target postgresql.service

[Service]
Type=simple
User=myapi
Group=myapi
WorkingDirectory=/opt/myapi

ExecStart=/opt/myapi/bin/server --config /etc/myapi/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s

# Filesystem
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/myapi /var/log/myapi
ReadOnlyPaths=/etc/myapi
PrivateTmp=true
TemporaryFileSystem=/tmp:rw

# Privileges
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

# Kernel protection
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectKernelLogs=true
ProtectClock=true
ProtectControlGroups=true
ProtectHostname=true

# Syscall filtering
SystemCallFilter=@system-service @network-io
SystemCallArchitectures=native
SystemCallErrorNumber=EPERM

# Network
RestrictAddressFamilies=AF_INET AF_INET6
RestrictNamespaces=true

# Misc
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
RemoveIPC=true

[Install]
WantedBy=multi-user.target

After saving and reloading:

systemctl daemon-reload
systemctl restart myapi
systemd-analyze security myapi.service

A well-configured unit file for a typical API should score below 3.

Using Override Files (Don't Modify Package Units Directly)

If you're hardening a package-provided unit file like nginx or postgresql, never edit /lib/systemd/system/nginx.service directly — your changes get overwritten on package upgrades. Use drop-in overrides instead:

# Create a drop-in directory
mkdir -p /etc/systemd/system/nginx.service.d/

# Create the override file
cat > /etc/systemd/system/nginx.service.d/hardening.conf << 'EOF'
[Service]
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/nginx /var/cache/nginx /run/nginx
NoNewPrivileges=true
ProtectKernelModules=true
ProtectKernelTunables=true
PrivateTmp=true
RestrictNamespaces=true
SystemCallFilter=@system-service @network-io
SystemCallArchitectures=native
EOF

systemctl daemon-reload
systemctl restart nginx

Verify the effective configuration merges correctly:

systemctl cat nginx.service   # Shows merged unit with all drop-ins

Iterating Safely: The Debug Approach

Don't apply all directives at once and hope for the best. Apply them incrementally:

# Run service temporarily with new settings without making them permanent
systemd-run --unit=myapp-test \
  -p ProtectSystem=strict \
  -p PrivateTmp=true \
  -p NoNewPrivileges=true \
  /opt/myapp/bin/server

# Watch journal for EPERM or access denied errors
journalctl -u myapp-test -f

If you hit SystemCallFilter blocking legitimate calls, use strace on the running process to identify exactly which syscalls the application uses:

strace -p $(pgrep myapp) -e trace=%file,%network 2>&1 | grep -v ENOENT | head -50

Systemd sandboxing isn't a replacement for proper network segmentation or secrets management — but it's the cheapest security win available to you. A service that can't read /etc/shadow, can't write to /tmp outside its private space, and can't call ptrace has dramatically reduced blast radius when something goes wrong.

Share:

Was this article helpful?

Raafay Asif
Raafay Asif

Linux Systems Engineer

Everything runs on Linux — I make sure it runs well. From kernel tuning to systemd debugging, I live in the terminal. If your server is misbehaving, I've probably seen that exact dmesg output before.

Related Articles

More in Linux

View all →

Discussion