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

1
ProtectSystem=strict

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.

1
ProtectHome=true

Makes /home, /root, and /run/user inaccessible. Set to read-only if the service needs to read (but not write) home directories.

1
PrivateTmp=true

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
1
NoNewPrivileges=true

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.

1
ProtectKernelLogs=true

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).

1
LockPersonality=true

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

  1. Start with full hardening
  2. If service fails, check journalctl -u myservice
  3. Look for “Permission denied” or “Operation not permitted”
  4. Selectively relax restrictions
  5. 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:

ScoreRisk Level
0-2Well hardened
2-4Reasonably secure
4-6Room 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.