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 who it runs as, files it must write, ports it listens on, and any special kernel needs.
- Reduce privilege: run as a dedicated user, drop Linux capabilities, and block privilege escalation.
- Lock down filesystem: make everything read-only by default, then allow explicit write paths.
- Isolate devices and namespaces: remove access to host devices and unsafe namespaces.
- Limit syscalls and memory execution: apply syscall filters and disable W^X violations.
- Constrain networking: deny all, then explicitly allow only what is needed.
- Handle secrets correctly: pass secrets as credentials or locked-down files, not inline env vars.
- Verify and iterate: load the unit, run security analysis, test functionality, review logs, 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 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=, and LogsDirectory= create dedicated directories owned by the service user with safe permissions. Prefer these over manual paths.
- ProtectSystem=strict makes most of the filesystem read-only. Permit needed writes with the XDirectory= options or ReadWritePaths=.
- CapabilityBoundingSet= and AmbientCapabilities= are empty to remove all capabilities unless explicitly required.
- IPAddressDeny=any together with IPAddressAllow= constrains all inbound and outbound network traffic.
- SystemCallFilter= uses a conservative profile. If the service breaks, inspect journal logs and add the specific syscall groups you need.
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_token should be root: root and mode 0400.
- The service reads the secret from $CREDENTIALS_DIRECTORY/api_token at 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, reload, 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, and needed env vars.
- 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= and SystemCallFilter=@system-service; restart and check logs for denied operations.
- Replace inline secrets with LoadCredential= and point the app to $CREDENTIALS_DIRECTORY.
- Validate:
- systemd-analyze security myapp.service and capture the score before/after.
- Functional checks: curl localhost:8080, file writes in /var/lib/myapp, logs in /var/log/myapp.
- ss -tulpn confirms only expected listeners.
- Rollback plan:
- Keep each change as a separate commit and comment block. If something fails, remove the last directive, 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/Allow, 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.