Most systemd service files are written for functionality, not security. The defaults give services more access than they need—full filesystem visibility, network capabilities, and the ability to spawn processes anywhere. A few directives can dramatically reduce the blast radius if that service gets compromised.
The Security Baseline#
Start with this template for any service that doesn’t need special privileges:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| [Service]
# Run as dedicated user
User=myservice
Group=myservice
# Filesystem restrictions
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myservice
# Network restrictions (if service doesn't need network)
# PrivateNetwork=true
# Capability restrictions
NoNewPrivileges=true
CapabilityBoundingSet=
AmbientCapabilities=
# System call filtering
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# Additional hardening
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
MemoryDenyWriteExecute=true
LockPersonality=true
|
Understanding Each Directive#
Filesystem Protection#
Mounts /usr, /boot, and /efi read-only. The strict level also makes /etc read-only. Use full if the service needs to write to /etc.
Makes /home, /root, and /run/user inaccessible. Set to read-only if the service needs to read (but not write) home directories.
Gives the service its own /tmp and /var/tmp, invisible to other processes. Prevents temp file attacks.
1
| ReadWritePaths=/var/lib/myservice
|
Explicitly whitelist directories the service needs to write. Combined with ProtectSystem=strict, this creates an allowlist model.
User and Privileges#
1
2
| User=myservice
Group=myservice
|
Never run services as root unless absolutely necessary. Create a dedicated system user:
1
| useradd -r -s /usr/sbin/nologin myservice
|
Prevents the service (and its children) from gaining privileges via setuid binaries or capability inheritance. This is one of the most important directives.
1
2
| CapabilityBoundingSet=
AmbientCapabilities=
|
Empty values strip all Linux capabilities. Add back only what’s needed:
1
2
3
| # Example: allow binding to privileged ports
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
System Call Filtering#
1
| SystemCallArchitectures=native
|
Only allows system calls for the native architecture. Prevents attacks using 32-bit syscall compatibility on 64-bit systems.
1
| SystemCallFilter=@system-service
|
Allows a predefined set of syscalls needed by typical services. Much safer than allowing all syscalls.
1
| SystemCallFilter=~@privileged @resources
|
The ~ prefix denies these syscall groups:
@privileged: Syscalls requiring elevated privileges@resources: Resource limit manipulation
Kernel Protection#
1
| ProtectKernelTunables=true
|
Makes /proc/sys, /sys, and other kernel tunables read-only.
1
| ProtectKernelModules=true
|
Prevents loading kernel modules.
Denies access to kernel log ring buffer.
1
| ProtectControlGroups=true
|
Makes cgroup hierarchy read-only.
Memory Protection#
1
| MemoryDenyWriteExecute=true
|
Prevents creating memory mappings that are both writable and executable. Blocks many exploitation techniques. Note: breaks JIT compilers (disable for Node.js, Java).
Locks the execution domain, preventing personality switches used in some attacks.
Real-World Examples#
Web Application (Node.js)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| [Unit]
Description=My Node.js App
After=network.target
[Service]
Type=simple
User=nodeapp
Group=nodeapp
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/node server.js
Restart=always
# Hardening
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/www/myapp/uploads /var/log/myapp
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
# Note: MemoryDenyWriteExecute=false for JIT
LockPersonality=true
[Install]
WantedBy=multi-user.target
|
Database (PostgreSQL-style)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| [Service]
User=postgres
Group=postgres
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/postgresql /var/run/postgresql
NoNewPrivileges=true
CapabilityBoundingSet=
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
|
Tunnel Daemon (like cloudflared)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| [Service]
User=cloudflared
Group=cloudflared
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadOnlyPaths=/etc/cloudflared
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged
|
Testing Your Hardening#
Check Current Security#
1
| systemd-analyze security myservice.service
|
This scores your service from 0 (most secure) to 10 (least secure) and lists specific issues.
Test Before Production#
1
2
3
4
5
6
| # Dry run with hardening
systemd-run --user --pty \
--property=ProtectSystem=strict \
--property=ProtectHome=true \
--property=NoNewPrivileges=true \
/path/to/your/binary
|
Iterative Hardening#
- Start with full hardening
- If service fails, check
journalctl -u myservice - Look for “Permission denied” or “Operation not permitted”
- Selectively relax restrictions
- Document why each relaxation is needed
Common Gotchas#
Service can’t write logs
1
2
3
4
5
6
7
| # Option 1: Use journal
StandardOutput=journal
StandardError=journal
# Option 2: Explicit log path
ReadWritePaths=/var/log/myservice
LogsDirectory=myservice
|
Service needs to read SSL certs
1
| ReadOnlyPaths=/etc/ssl/certs /etc/pki
|
Service needs DNS resolution
1
2
3
| # PrivateNetwork=true breaks DNS
# Either disable PrivateNetwork or use:
BindReadOnlyPaths=/run/systemd/resolve
|
JIT-compiled languages fail
1
2
| # Node.js, Java, Python with JIT
MemoryDenyWriteExecute=false
|
The Security Score Target#
Run systemd-analyze security on your services. Aim for:
| Score | Risk Level |
|---|
| 0-2 | Well hardened |
| 2-4 | Reasonably secure |
| 4-6 | Room for improvement |
| 6+ | Needs attention |
Default services often score 8-9. With these directives, you can typically get below 3.
Worth the Effort#
Every hardening directive you add is defense in depth. If an attacker exploits your web app:
- Without hardening: Full system access
- With hardening: Limited to
/var/www/app, can’t escalate privileges, can’t load kernel modules, can’t access other users’ data
The 15 minutes spent hardening a service file pays back in reduced blast radius when (not if) something goes wrong.