Intro
Hardened systemd units shrink the attack surface of Linux services by defaulting to least privilege, isolating files and devices, constraining network access, and sharply limiting what processes can do. This guide shows a practical workflow and copyable examples you can apply to new or existing services. You will learn how to run as an unprivileged user, lock down filesystem access, restrict capabilities and syscalls, manage secrets safely, control network exposure, and validate changes with safe checks.
Workflow Overview
- Profile the service: identify the user it runs as, files it must write, ports it listens on, and any special kernel needs (e.g.,
CAP_NET_BIND_SERVICEfor ports below 1024, orCAP_DAC_OVERRIDEfor filesystem ACLs). - Reduce privilege: run as a dedicated user, drop Linux capabilities, and block privilege escalation with
NoNewPrivileges=yes. - Lock down filesystem: make everything read-only by default with
ProtectSystem=strict, then allow explicit write paths viaStateDirectory=,CacheDirectory=,LogsDirectory=, orReadWritePaths=. - Isolate devices and namespaces: remove access to host devices (
PrivateDevices=yes) and unsafe namespaces (RestrictNamespaces=yes). - Limit syscalls and memory execution: apply syscall filters (
SystemCallFilter=@system-service) and disable W^X violations (MemoryDenyWriteExecute=yes). - Constrain networking: deny all with
IPAddressDeny=any, then explicitly allow only required addresses and families. - Handle secrets correctly: pass secrets via
LoadCredential=and read them from$CREDENTIALS_DIRECTORY, never as inline environment variables. - Verify and iterate: load the unit, run
systemd-analyze security, test functionality, review logs for denials, and roll back cleanly if needed.
Practical Hardening Examples
Use these snippets as a starting point and adapt to your service.
1) Baseline hardened service template
This template assumes a simple TCP service that binds to localhost on port 8080 and needs to write state and logs.
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp (hardened)
After=network-online.target
Wants=network-online.target
[Service]
# Command
ExecStart=/usr/local/bin/myapp --listen 127.0.0.1:8080
# Least privilege
User=myapp
Group=myapp
UMask=0077
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
# Filesystem protections
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectControlGroups=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
# Explicit write locations managed by systemd
StateDirectory=myapp
CacheDirectory=myapp
LogsDirectory=myapp
# If you must allow writes elsewhere, declare them explicitly
# ReadWritePaths=/var/lib/myapp /var/log/myapp
# Memory and personality hardening
MemoryDenyWriteExecute=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
SystemCallArchitectures=native
# Conservative allowlist; adjust for your app
SystemCallFilter=@system-service
# Network controls: default deny, allow loopback only
IPAddressDeny=any
IPAddressAllow=127.0.0.1
IPAddressAllow=::1
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Environment and credentials
EnvironmentFile=-/etc/myapp/myapp.env
# Provide secrets via credentials; read using $CREDENTIALS_DIRECTORY
# File must be root:root, mode 0400 or stricter
LoadCredential=api_token:/etc/credentials/myapp_api_token
# Restart policy
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target
Notes:
StateDirectory=,CacheDirectory=, andLogsDirectory=create dedicated directories under/var/lib/,/var/cache/, and/var/log/owned by the service user with mode 0750. Prefer these over manual paths.ProtectSystem=strictmakes/usr,/boot, and/etcread-only. Permit needed writes with theXDirectory=options orReadWritePaths=.CapabilityBoundingSet=andAmbientCapabilities=empty removes all capabilities unless explicitly required.IPAddressDeny=anytogether withIPAddressAllow=constrains all inbound and outbound network traffic to the listed addresses.SystemCallFilter=@system-serviceuses a conservative profile. If the service breaks, inspectjournalctl -u myapp.service -bforSECCOMPdenials and add the specific syscall groups you need (e.g.,@system-service @network-io).
2) Service with no network access at all
# /etc/systemd/system/batch-job.service
[Unit]
Description=Nightly batch job (isolated)
After=local-fs.target
[Service]
ExecStart=/usr/local/bin/batch-job
User=batch
Group=batch
UMask=0077
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictSUIDSGID=yes
MemoryDenyWriteExecute=yes
LockPersonality=yes
RestrictNamespaces=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
# Hard network isolation
PrivateNetwork=yes
RestrictAddressFamilies=AF_UNIX
IPAddressDeny=any
StateDirectory=batch-job
LogsDirectory=batch-job
Restart=on-failure
3) Passing secrets safely with credentials
Avoid placing raw secrets directly in unit files. Instead, use LoadCredential= and read them from the credentials directory.
# /etc/systemd/system/report.service
[Unit]
Description=Report generator
[Service]
ExecStart=/usr/local/bin/report --token-file "$CREDENTIALS_DIRECTORY/api_token"
User=report
Group=report
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
LoadCredential=api_token:/etc/credentials/report_api_token
StateDirectory=report
LogsDirectory=report
On disk:
/etc/credentials/report_api_tokenshould be owned byroot:rootwith mode0400.- The service reads the secret from
$CREDENTIALS_DIRECTORY/api_tokenat runtime.
4) Safe checks and validation
# Syntax and unit integrity
systemd-analyze verify /etc/systemd/system/myapp.service
# Load changes and restart safely
systemctl daemon-reload
systemctl restart myapp.service
# Inspect logs for denials and missing permissions
journalctl -u myapp.service -b
# Security score and hints
systemd-analyze security myapp.service
# Confirm effective configuration
systemctl show -p User,Group,NoNewPrivileges,CapabilityBoundingSet myapp.service
# Check network exposure
ss -tulpn | grep 8080
If a tightening breaks functionality, revert gradually: comment out the last change, run systemctl daemon-reload, restart, and retest. Add only the minimum required permissions or syscalls to restore correct behavior.
Local Pilot Plan
Goal: apply a hardened baseline to one non-critical service and verify behavior locally before wider rollout.
Scope
- Choose a single service with clear health checks (for example, an internal HTTP service on localhost).
Plan
- Capture current behavior: which user it runs as, files it writes, ports it listens on, and needed environment variables.
- Create a drop-in override rather than editing the vendor unit:
systemctl edit myapp.service
Add: User=, Group=, NoNewPrivileges=yes, ProtectSystem=strict, PrivateTmp=yes, CapabilityBoundingSet=, IPAddressDeny=any with loopback allow.
- Add
StateDirectory=,LogsDirectory=and move writes there; remove ad-hoc write paths. - Add
RestrictAddressFamilies=andSystemCallFilter=@system-service; restart and check logs for denied operations. - Replace inline secrets with
LoadCredential=and point the app to$CREDENTIALS_DIRECTORY. - Validate:
- Run
systemd-analyze security myapp.serviceand capture the score before/after. - Functional checks:
curl localhost:8080, verify file writes in/var/lib/myapp, logs in/var/log/myapp. ss -tulpnconfirms only expected listeners.
- Rollback plan: keep each change as a separate commit and comment block. If something fails, remove the last directive, run
systemctl daemon-reload, restart, and re-test.
Exit criteria
- Service runs reliably through normal operations.
- Only expected files are writable.
- Only intended ports are reachable.
- Security score improves without breaking functionality.
Conclusion
Hardened systemd units provide strong, repeatable defenses with least privilege, explicit filesystem permissions, strict network limits, and safe secret handling. Start with a small pilot, measure improvements, and iterate. As a durable baseline, run as a dedicated user, set NoNewPrivileges=yes, drop all capabilities, make the filesystem read-only by default, allow only required write paths, restrict address families, apply IPAddressDeny/IPAddressAllow, and use credentials for secrets. Validate with systemd-analyze verify and systemd-analyze security, observe the journal for denials, and expand the pattern across services once stable.