A systemd unit decides who a service runs as, what it may touch and what happens when it crashes. The defaults give a service nearly everything root has, but a few directives close most of that off for almost no effort.
Create a system user with no login shell and name it in User=. Add NoNewPrivileges=true so the process cannot gain rights through a setuid binary, and drop capabilities with an empty CapabilityBoundingSet=. A service that must listen on port 80 or 443 keeps only CAP_NET_BIND_SERVICE.
[Service]
User=myapp
NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICEProtectSystem=strict mounts the whole filesystem read-only for the service, ProtectHome=true hides /home and /root, and PrivateTmp=true gives it its own /tmp. The service then needs an explicit place to write: StateDirectory=myapp creates /var/lib/myapp owned by the service user and keeps it writable.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
StateDirectory=myappMemoryDenyWriteExecute=true forbids memory that is both writable and executable, which stops a class of exploit but also breaks every just-in-time compiler. Node.js, Java, .NET and LuaJIT all need such memory, so leave it off for them and enable it for Go, Rust and C programs.
A .timer unit starts a service on a schedule. Persistent=true runs a job that was missed while the machine was off at its next boot, and RandomizedDelaySec spreads the start times of many machines. Enable the timer, not the service, and check a schedule with systemd-analyze calendar before relying on it.
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=trueOpen the systemd service & timer generator
Run systemd-analyze security followed by the unit name. It scores each sandboxing directive and lists what is still open, which is a useful guide for what to tighten next.
The service tried to write somewhere that is now read-only. Check journalctl for permission errors, then add StateDirectory= or ReadWritePaths= for the paths it legitimately needs.
systemd expands % specifiers such as %h and %u in most values and $ variables in ExecStart, so a stray one silently changes the command. Put variable data in an EnvironmentFile instead.