LinuxVII · systemd and Service ManagementUnit authoring
Writing systemd unit files
What you'll learn
- Write a basic [Service] unit that runs a daemon
- Configure ExecStart, Restart, EnvironmentFile, and User/Group
- Distinguish ordering (After=/Before=) from dependency (Requires=/Wants=)
- Recover a unit from start-limit-hit with systemctl reset-failed
- Use systemd's resource-control directives (CPUQuota, MemoryMax, IOWeight)
- Apply hardening directives (NoNewPrivileges, ProtectSystem, ProtectHome)
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09
Most production daemons ship their own unit files. When you need your own — for a custom application, a deployment hook, or a monitoring agent — you write a unit file. The format is INI-like with sections and directives.
A minimal service unit
[Unit]
Description=MyApp daemon
Documentation=https://wiki.example.com/myapp
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/opt/myapp/bin/myapp
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Place this in /etc/systemd/system/myapp.service, then:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
$ systemctl cat myapp# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp daemon
...Illustrative output
The [Unit] section
| Directive | Meaning |
|---|---|
Description= | One-line human-readable description |
Documentation= | URLs to upstream documentation |
After= | ORDERING ONLY — if the named unit is also being started in this transaction, wait for it. Does NOT pull it in and does NOT require success. |
Before= | ORDERING ONLY — the inverse of After=. |
Requires= | Hard dependency — pulls the named unit in; this unit is stopped if the named unit fails or is stopped. No ordering. |
Wants= | Soft dependency — pulls the named unit in, but ignores its failure. No ordering. |
Conflicts= | Cannot be active at the same time |
StartLimitIntervalSec= / StartLimitBurst= | Start rate limit for the unit (see below). Note both live in [Unit], not [Service]. |
To both order and depend, you need two directives: pair
After=X with Wants=X or Requires=X.
The [Service] section
| Directive | Meaning |
|---|---|
Type=simple | Process started by ExecStart is the main PID (default) |
Type=forking | Process forks and exits; the child is the daemon |
Type=notify | Process sends sd_notify when ready |
Type=oneshot | Process runs once and exits (for setup tasks) |
ExecStart= | The command to run. Required. |
ExecReload= | Command to run on systemctl reload |
ExecStop= | Command to run on systemctl stop |
ExecStartPre= | Commands to run before ExecStart |
ExecStartPost= | Commands to run after ExecStart |
Restart= | When to restart: no, always, on-success, on-failure, on-abnormal, on-watchdog, on-abort. Subject to the start rate limit — see below. |
RestartSec= | Time to wait before restarting. Always set it; the 100 ms default is what turns a failure into a crash loop. |
User= / Group= | Run as this user/group |
WorkingDirectory= | Working directory for the process |
Environment= | Inline environment variable |
EnvironmentFile= | File with key=value pairs |
TimeoutStopSec= | Maximum time to wait for graceful stop |
$ systemctl show myapp.service | grep -E '^(Type|ExecStart|Restart|User)'Type=simple
ExecStart={ path=/opt/myapp/bin/myapp ; argv[]=/opt/myapp/bin/myapp }
Restart=on-failure
User=rootIllustrative output
Restart=, start rate limits, and reset-failed
Restart= is only half of a restart policy. systemd also rate-limits
how often a unit may start. Exceed the limit and the unit lands in
a failed state with Result: start-limit-hit, and every subsequent
systemctl start or systemctl restart is refused — including
the one you issue after fixing the problem.
The limit is StartLimitBurst starts within StartLimitIntervalSec,
defaulting to DefaultStartLimitBurst= / DefaultStartLimitIntervalSec=
from the manager configuration — 5 starts in 10 seconds on a stock
system. Both directives live in [Unit], which is a common
authoring mistake:
[Unit]
Description=MyApp daemon
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
Restart=on-failure
RestartSec=5
Restart=always on a service that fails during start-up produces a
crash loop that burns CPU and floods the journal until the rate limit
trips. Prefer Restart=on-failure, always set RestartSec=, and size
StartLimitBurst deliberately rather than inheriting the 5-in-10s
default.
The operator half of the competency is the recovery:
systemctl status myapp
# Active: failed (Result: start-limit-hit)
# Job for myapp.service failed because the control process...
systemctl reset-failed myapp # clears the failed state AND the rate-limit counter
systemctl start myapp
Resource controls
[Service] supports a wide range of resource directives that translate to cgroup settings:
| Directive | cgroup setting | Meaning |
|---|---|---|
CPUQuota=200% | cpu.max | Up to 2 cores of CPU |
MemoryMax=2G | memory.max | Hard memory limit; OOM-kill if exceeded |
MemoryHigh=1G | memory.high | Soft memory limit; throttle if exceeded |
IOWeight=100 | io.weight | Relative I/O priority |
TasksMax=256 | pids.max | Maximum number of processes/threads |
$ systemctl show myapp.service | grep -E '^(CPUQuota|MemoryMax|IOWeight|TasksMax)'CPUQuota=200%
MemoryMax=2G
MemoryHigh=1G
IOWeight=100
TasksMax=256Illustrative output
Security hardening directives
systemd supports a rich set of security directives that restrict what the service can do:
| Directive | Effect |
|---|---|
NoNewPrivileges=true | Process cannot gain new privileges (setuid, capabilities) |
ProtectSystem=strict | Filesystem is read-only except for /dev, /proc, /sys — pair it with the writable-path directives below |
StateDirectory= | Creates and owns /var/lib/<name>, writable under ProtectSystem=strict |
LogsDirectory= | Creates and owns /var/log/<name>, writable under ProtectSystem=strict |
RuntimeDirectory= | Creates /run/<name>, writable, removed on stop |
ConfigurationDirectory= | Creates /etc/<name> |
ReadWritePaths= | Re-opens specific paths outside those conventions |
ProtectHome=true | /home, /root, /run/user are inaccessible |
PrivateTmp=true | Service gets its own /tmp |
PrivateDevices=true | Service sees only /dev/null, /dev/zero, /dev/random, /dev/urandom |
ProtectKernelTunables=true | /proc and /sys are read-only or filtered |
ProtectControlGroups=true | Service cannot write to its own cgroup |
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 | Limit socket families |
RestrictNamespaces=true | No new namespaces (no CLONE_NEWNS, no CLONE_NEWPID, …) |
LockPersonality=true | Lock the execution domain |
MemoryDenyWriteExecute=true | No W^X memory mappings |
NoExecPaths= / ExecPaths= | Limit which paths can have executables |
SystemCallFilter= | seccomp filter — allow only specific syscalls |
SystemCallArchitectures=native | Limit to native architecture (no i386 syscalls on amd64) |
User= / Group= | Run as a non-root user |
CapabilityBoundingSet= | Limit Linux capabilities |
NoNewPrivileges=true | Together with the above, this is the modern sandbox baseline |
- Place the unit file in /etc/systemd/system/.
- Run
systemctl daemon-reloadafter every edit. - Use
systemd-analyze verify <unit>to catch syntax errors before activating. - Add hardening directives incrementally. Test after each
- Use drop-ins for partial overrides —
/etc/systemd/system/<unit>.d/override.confis cleaner than rewriting the unit file - Document resource limits in the runbook. A reviewer should know why CPUQuota=200% was chosen
Knowledge check
Knowledge check · 5 questions
Q1. Where should a host-specific unit override live?
Q2. `MemoryHigh=2G` throttles a service that goes over 2 GB, while `MemoryMax=2G` lets the kernel kill it.
Q3. Which of the following are correct systemd hardening practices? Select all that apply.
Q4. A bad config crash-looped myapp. You have already rolled the config back, but `systemctl restart myapp` returns "Unit myapp.service has a start request repeated too quickly." What is the correct next command?
Q5. `After=postgresql.service` on an application unit is enough to guarantee postgres is running before the application starts.
Passing score: 75%. Answers are checked in this browser.