Systemd Service Hardening: Sandbox Your Daemons
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 @:
| Group | Includes |
|---|---|
@system-service | Typical server daemon syscalls |
@network-io | Socket operations |
@file-system | File open/read/write/stat |
@process | fork, exec, wait |
@basic-io | read, 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.
Was this article helpful?
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
Rocky Linux 9 Server Hardening: Security Baseline from First Boot
A production security baseline for Rocky Linux 9 — covering SELinux configuration, firewalld rules, SSH hardening, auditd, AIDE integrity checking, and CIS Benchmark compliance checks.
Ubuntu 24.04 LTS Server Setup for Production: From Bare Metal to Hardened Baseline
A complete walkthrough for turning a fresh Ubuntu 24.04 LTS installation into a production-ready server — from initial SSH lockdown to swap configuration and kernel parameter tuning.
Ubuntu Zero-Downtime Patching: Unattended Upgrades, Livepatch, and USN Tracking
How to keep Ubuntu servers patched automatically without unplanned reboots — combining unattended-upgrades, Canonical Livepatch, and USN monitoring into a practical patching strategy.
Systemd & Service Management: Master systemctl and Unit Files
Manage Linux services with systemctl, write custom unit files, understand the boot process, configure targets, and use journalctl for log analysis.
Linux User & Group Management: From Root to Least Privilege
Create and manage Linux users and groups, configure sudo access, understand /etc/passwd and /etc/shadow, and implement PAM authentication policies.
Alpine Linux apk Reference: Package Management, Repositories, and System Administration
A complete reference for Alpine Linux's apk package manager — adding packages, managing repositories, pinning versions, building custom packages, and running Alpine as a minimal server OS.
More in Linux
View all →Linux Control Groups (cgroups V2): Limiting CPU And Memory For Production Workloads
If you're running production workloads without cgroup constraints, you're one runaway process away from a bad day. Here's everything you need to configure...
Alpine Linux for Docker: Building Minimal, Secure Container Images
How to use Alpine Linux as a Docker base image effectively — understanding musl libc compatibility, multi-stage builds, layer optimization, and the security trade-offs versus glibc-based distributions.
openSUSE MicroOS and Transactional Updates: Atomic System Updates with Btrfs Snapshots
How openSUSE's transactional-update and Btrfs snapshot integration enables atomic, rollback-safe system updates — and how MicroOS takes this to a fully immutable, container-optimized OS.
openSUSE and SUSE Linux Enterprise: YaST Administration and zypper Package Management
A practical guide to SUSE Linux administration — using YaST for system configuration, zypper for package management, managing repositories, and understanding the differences between openSUSE Leap, Tumbleweed, and SUSE Linux Enterprise.