Skip to main content
RunBook Academy

LinuxVII · systemd and Service ManagementUnit authoring

Writing systemd unit files

Intermediate⏱ ~14 minbashsystemctlsystemd-analyze

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

Not yet marked complete on this device.

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
Read-only / Safesystemctl cat
$ systemctl cat myapp
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp daemon
...

Illustrative output

The [Unit] section

DirectiveMeaning
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

DirectiveMeaning
Type=simpleProcess started by ExecStart is the main PID (default)
Type=forkingProcess forks and exits; the child is the daemon
Type=notifyProcess sends sd_notify when ready
Type=oneshotProcess 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
Read-only / Safeshow
$ 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=root

Illustrative 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:

Directivecgroup settingMeaning
CPUQuota=200%cpu.maxUp to 2 cores of CPU
MemoryMax=2Gmemory.maxHard memory limit; OOM-kill if exceeded
MemoryHigh=1Gmemory.highSoft memory limit; throttle if exceeded
IOWeight=100io.weightRelative I/O priority
TasksMax=256pids.maxMaximum number of processes/threads
Read-only / Saferesource controls in effect
$ systemctl show myapp.service | grep -E '^(CPUQuota|MemoryMax|IOWeight|TasksMax)'
CPUQuota=200%
MemoryMax=2G
MemoryHigh=1G
IOWeight=100
TasksMax=256

Illustrative output

Security hardening directives

systemd supports a rich set of security directives that restrict what the service can do:

DirectiveEffect
NoNewPrivileges=trueProcess cannot gain new privileges (setuid, capabilities)
ProtectSystem=strictFilesystem 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=trueService gets its own /tmp
PrivateDevices=trueService sees only /dev/null, /dev/zero, /dev/random, /dev/urandom
ProtectKernelTunables=true/proc and /sys are read-only or filtered
ProtectControlGroups=trueService cannot write to its own cgroup
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6Limit socket families
RestrictNamespaces=trueNo new namespaces (no CLONE_NEWNS, no CLONE_NEWPID, …)
LockPersonality=trueLock the execution domain
MemoryDenyWriteExecute=trueNo W^X memory mappings
NoExecPaths= / ExecPaths=Limit which paths can have executables
SystemCallFilter=seccomp filter — allow only specific syscalls
SystemCallArchitectures=nativeLimit to native architecture (no i386 syscalls on amd64)
User= / Group=Run as a non-root user
CapabilityBoundingSet=Limit Linux capabilities
NoNewPrivileges=trueTogether with the above, this is the modern sandbox baseline
  1. Place the unit file in /etc/systemd/system/.
  2. Run systemctl daemon-reload after every edit.
  3. Use systemd-analyze verify <unit> to catch syntax errors before activating.
  4. Add hardening directives incrementally. Test after each
  5. Use drop-ins for partial overrides/etc/systemd/system/<unit>.d/override.conf is cleaner than rewriting the unit file
  6. Document resource limits in the runbook. A reviewer should know why CPUQuota=200% was chosen

Knowledge check

Knowledge check · 5 questions

  1. Q1. Where should a host-specific unit override live?

  2. Q2. `MemoryHigh=2G` throttles a service that goes over 2 GB, while `MemoryMax=2G` lets the kernel kill it.

  3. Q3. Which of the following are correct systemd hardening practices? Select all that apply.

  4. 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?

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