systemd Unit Authoring
- Ship unit files in
/etc/systemd/system/only when you are the admin. Packages put them in/usr/lib/systemd/system/; local overrides go in drop-ins under/etc/systemd/system/<unit>.d/. - Pick
Type=based on what the process actually does on startup, not what is convenient:simpleif it foregrounds,notifyif it calls sd_notify,forkingif it daemonises,oneshotfor one-and-done. After=is ordering,Requires=/Wants=is activation. Mixing them up is the most common unit bug.- Every long-running unit should have
Restart=,RestartSec=, and a hardening baseline (ProtectSystem,PrivateTmp,NoNewPrivileges). - Always
systemctl daemon-reloadafter editing a unit or drop-in.systemd-analyze verifycatches typos before you reload.
- Unit file anatomy
- Type= values
- ExecStart, ExecStartPre, ExecStartPost, ExecStop
- Restart= policies and rate limits
- Dependency ordering: After/Wants/Requires/BindsTo
- Environment and working dir
- Hardening: ProtectSystem, PrivateTmp, caps, syscalls
- Journal integration
- Drop-ins
- Timers
- Socket activation
- User units
- Example 1 — simple notify service
- Example 2 — hardened Python worker
- Example 3 — oneshot plus timer
- Troubleshooting
- Cross-reference
Unit file anatomy
A service unit has three sections. [Unit] describes the unit and its dependencies. [Service] describes how to run the process. [Install] tells systemctl enable where to hook the unit.
[Unit]
Description=Example app
Documentation=https://example.com/docs
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/example --config /etc/example.conf
User=example
Group=example
Restart=on-failure
RestartSec=3s
[Install]
WantedBy=multi-user.target
Canonical paths:
| Path | Who writes there | Precedence |
|---|---|---|
/usr/lib/systemd/system/ | RPM/DEB packages | Lowest |
/run/systemd/system/ | Runtime-generated | Middle |
/etc/systemd/system/ | Local admin | Highest |
/etc/systemd/system/<unit>.d/*.conf | Drop-ins (admin) | Merged on top |
~/.config/systemd/user/ | User units | Per-user |
Type= values
Picking the wrong type is the most common cause of systemctl start "succeeding" while the service is actually broken.
Type=simple- Default. The process specified by
ExecStartis the main process and runs in the foreground. systemd considers the unit "active" as soon as it forks the process — it does not wait for the process to be ready. Use when the program has no readiness signal. Type=exec- Like
simple, but systemd waits untilexecve()has completed before considering the unit started. Prefer oversimplewhen available (systemd ≥ 240). Type=notify- The daemon calls
sd_notify(READY=1)when initialisation is complete. systemd only marks the unit active after that message. This is what you want for anything with a real readiness signal (nginx innotifymode, a long-booting Java app after it opens its port). Type=forking- The program double-forks and exits; the real daemon keeps running. systemd tracks the grandchild via
PIDFile=. Legacy pattern — avoid writing new daemons this way. Type=oneshot- Runs one or more
ExecStart=lines to completion, then exits. By default the unit is considered inactive once finished; addRemainAfterExit=yesto keep it "active" (useful for setup jobs whose effect persists). Type=idle- Like
simple, but execution is delayed until active jobs are dispatched. Used for getty so the console is quiet during boot. Type=dbus- Unit is considered ready when the named bus name appears on the system bus (
BusName=).
ExecStart, ExecStartPre, ExecStartPost, ExecStop
[Service]
Type=simple
ExecStartPre=/usr/bin/install -d -o app -g app -m 0750 /var/lib/app
ExecStartPre=-/usr/bin/rm -f /run/app/app.sock
ExecStart=/usr/local/bin/app --config /etc/app.conf
ExecStartPost=/usr/local/bin/healthcheck.sh
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=/bin/kill -TERM $MAINPID
TimeoutStartSec=30s
TimeoutStopSec=15s
- A leading
-on an Exec line (ExecStartPre=-/usr/bin/rm -f ...) means "ignore non-zero exit". Use for cleanups that may or may not find anything. - A leading
+runs with full privileges even ifUser=is set — sometimes needed for a pre-step that touches a root-owned path. ExecStartmust be an absolute path. No shell metacharacters unless you launch/bin/sh -cexplicitly.- Use
$MAINPID,$EXIT_STATUS, and$INVOCATION_IDin post/stop hooks.
ExecStart=/usr/bin/foo > /var/log/foo.log does not redirect anything — the > is a literal argument. Use journald for logs or wrap with /bin/sh -c 'foo >> /var/log/foo.log' if you truly need redirection.
Restart= policies and rate limits
| Value | Restart on… |
|---|---|
no | Never (default) |
on-success | Clean exit 0 or SIGHUP/SIGINT/SIGTERM/SIGPIPE |
on-failure | Non-zero exit, signal (not clean), timeout, or watchdog |
on-abnormal | Signal, timeout, watchdog (not clean non-zero exits) |
on-abort | Uncaught signal only |
on-watchdog | Only when watchdog timeout fires |
always | Every exit, clean or not |
[Service]
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60s
StartLimitBurst=5 # if 5 restarts inside 60s, stop trying
StartLimitIntervalSec= and StartLimitBurst= live in [Unit] on older systemd, in [Service] on newer. Without them, a crash-loop can pin a CPU re-executing a broken binary.
Dependency ordering: After/Wants/Requires/BindsTo
Ordering (After=, Before=) and activation (Wants=, Requires=, Requisite=, BindsTo=) are orthogonal. Most bugs are from confusing them.
After=foo.service- Order only. If
foois being started, finish it before me. Does not causefooto start. Wants=foo.service- When I am started, also start
foo. Iffoofails, I keep going. Most common coupling. Requires=foo.service- When I am started, also start
foo. Iffoofails to start or is stopped later, I am stopped too. Strong. Requisite=foo.service- Fail fast: if
foois not already active, fail immediately without starting it. BindsTo=foo.service- Like Requires but also reacts to sudden termination of
foo, not just clean stop. PartOf=foo.service- When
foois restarted or stopped, do the same to me. Conflicts=bar.service- Starting me stops bar, and vice versa.
[Unit]
Description=App
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
After=network.target only means "the network subsystem is initialised" — interfaces may still be coming up. After=network-online.target + Wants=network-online.target is the correct pair for anything that must reach external hosts at startup.
Environment and working dir
[Service]
WorkingDirectory=/var/lib/app
Environment="DB_URL=postgres://app@127.0.0.1/app"
Environment="LOG_LEVEL=info"
EnvironmentFile=/etc/app/app.env # KEY=VALUE lines; '-' prefix = optional
EnvironmentFile=-/etc/app/app.local # ignore if missing
PassEnvironment=HTTP_PROXY NO_PROXY # inherit from systemd's environment
EnvironmentFile=parses shell-styleKEY=VALUE, but it is not a shell — no$FOOsubstitution, noexport.- Prefix with
-to make the file optional. Useful for split secret/non-secret files. - Avoid placing secrets in
Environment=directly; they show up insystemctl show.
Hardening: ProtectSystem, PrivateTmp, caps, syscalls
Treat the following as a default baseline for any service that does not specifically need more privilege:
[Service]
User=app
Group=app
DynamicUser=no # set yes if you do not need a stable UID
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict # /usr, /boot, /etc read-only
ProtectHome=yes # /home, /root, /run/user hidden
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ReadWritePaths=/var/lib/app /var/log/app
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
UMask=0027
| Directive | Effect |
|---|---|
ProtectSystem=strict | /usr, /boot, /efi read-only; /etc read-only |
ProtectHome=yes | /home, /root, /run/user invisible or empty |
PrivateTmp=yes | Private /tmp and /var/tmp, cleaned up on exit |
NoNewPrivileges=yes | Blocks setuid/setcap binaries from escalating |
ReadWritePaths= | Whitelist of paths that remain writable under strict mode |
CapabilityBoundingSet= | Caps the process can ever have. Empty = none. |
AmbientCapabilities= | Caps granted to unprivileged User= (e.g. to bind <1024) |
SystemCallFilter=@system-service | Allow the common daemon syscall set; deny everything else |
MemoryDenyWriteExecute=yes | Blocks W^X violations — breaks some JITs, fine for most apps |
systemd-analyze security <unit> gives a score (0.0 safest, 10.0 worst) and lists what each directive improves. systemctl --user status plus journalctl -u unit -b will tell you if hardening broke the service.
Journal integration
[Service]
StandardOutput=journal
StandardError=journal
SyslogIdentifier=app # tag
SyslogLevel=info
LogRateLimitIntervalSec=30s
LogRateLimitBurst=1000
- Default is
journal, so most units need no explicit setting. - Use
StandardOutput=append:/var/log/app/app.logto write a file in addition (rare; prefer journald +journalctl --output=jsonpipelines). - If a daemon emits massive log volume at startup, raise
LogRateLimitBurstor disable it (LogRateLimitIntervalSec=0) to avoid dropped lines.
Drop-ins
Never edit a vendor unit in /usr/lib/systemd/system/. Create a drop-in that overrides or appends:
systemctl edit sshd.service # creates /etc/systemd/system/sshd.service.d/override.conf
systemctl edit --full sshd.service # full copy to /etc/systemd/system/ (prefer drop-in)
systemctl daemon-reload
systemctl restart sshd
# /etc/systemd/system/sshd.service.d/override.conf
[Service]
LimitNOFILE=65535
# To CLEAR an inherited list, assign empty first:
ExecStart=
ExecStart=/usr/sbin/sshd -D -f /etc/ssh/sshd_config.custom
ExecStart= a second time adds another command. Clear the list by assigning the empty value first, then set the new one. Same trick for Environment=, ReadWritePaths=, After=.
Timers
Timers are units of type .timer that activate a matching .service. Prefer them over cron for anything that needs dependency handling, persistent catch-up, or tight journald integration.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
Nice=10
IOSchedulingClass=idle
# /etc/systemd/system/backup.timer
[Unit]
Description=Run nightly backup
[Timer]
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=10m
Persistent=true
AccuracySec=1min
Unit=backup.service
[Install]
WantedBy=timers.target
systemctl enable --now backup.timer
systemctl list-timers --all
systemd-analyze calendar "*-*-* 02:15:00" --iterations=3
Persistent=trueruns the job on next boot if it was missed while the host was off. Without it, missed runs are lost.RandomizedDelaySec=jitters the start — crucial on fleets to avoid stampeding NFS, IPA, or a CI runner pool.OnBootSec=5min+OnUnitActiveSec=1his the idiom for "5 minutes after boot, then every hour".
Socket activation
systemd can listen on a port and only start the service when a connection arrives — useful for rarely-used services, inetd-style tools, and faster boot.
# /etc/systemd/system/echo.socket
[Unit]
Description=Echo on 2007
[Socket]
ListenStream=2007
Accept=no
NoDelay=true
[Install]
WantedBy=sockets.target
# /etc/systemd/system/echo.service
[Unit]
Requires=echo.socket
After=echo.socket
[Service]
ExecStart=/usr/local/bin/echod
StandardInput=socket
systemctl enable --now echo.socket
ss -ltnp | grep 2007
nc localhost 2007
With Accept=yes, systemd forks a template unit (echo@.service) per connection; with Accept=no the daemon inherits the listening socket via fd 3 and handles concurrency itself. Most modern daemons want the latter.
User units
Per-user services live under ~/.config/systemd/user/ and are managed with systemctl --user. They run inside a user manager started at login.
loginctl enable-linger alice # allow alice's user manager to run without an active session
mkdir -p ~/.config/systemd/user/
$EDITOR ~/.config/systemd/user/mybot.service
systemctl --user daemon-reload
systemctl --user enable --now mybot
journalctl --user -u mybot -f
[Unit]
Description=User bot
[Service]
Type=simple
ExecStart=%h/bin/bot.py
Restart=on-failure
[Install]
WantedBy=default.target
Specifiers worth knowing: %h user home, %u user name, %U UID, %t runtime dir (/run/user/$UID), %n full unit name, %i instance name (for foo@bar.service).
Example 1 — simple notify service
# /etc/systemd/system/metricsd.service
[Unit]
Description=Metrics collector
Documentation=https://example.com/metricsd
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
NotifyAccess=main
WatchdogSec=30s
ExecStart=/usr/local/bin/metricsd --config /etc/metricsd.yml
Restart=on-failure
RestartSec=3s
StartLimitIntervalSec=60s
StartLimitBurst=5
User=metricsd
Group=metricsd
DynamicUser=no
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/metricsd
CapabilityBoundingSet=
AmbientCapabilities=
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
Example 2 — hardened Python worker
# /etc/systemd/system/billing-worker@.service
[Unit]
Description=Billing worker %i
After=network-online.target postgresql.service redis.service
Wants=network-online.target
Requires=postgresql.service redis.service
PartOf=billing.target
[Service]
Type=simple
EnvironmentFile=/etc/billing/worker.env
EnvironmentFile=-/etc/billing/worker.local
WorkingDirectory=/opt/billing
ExecStart=/opt/billing/venv/bin/python -m billing.worker --queue %i
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGTERM
TimeoutStopSec=30s
Restart=always
RestartSec=5s
User=billing
Group=billing
UMask=0027
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
ReadWritePaths=/var/lib/billing /var/log/billing
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @mount @reboot
CapabilityBoundingSet=
LimitNOFILE=32768
LimitNPROC=512
[Install]
WantedBy=multi-user.target
systemctl enable --now billing-worker@invoices.service
systemctl enable --now billing-worker@statements.service
systemctl status 'billing-worker@*'
Example 3 — oneshot plus timer
# /etc/systemd/system/certbot-renew.service
[Unit]
Description=Renew Let's Encrypt certs
Documentation=https://certbot.eff.org/docs/using.html
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot renew --quiet --agree-tos --deploy-hook "systemctl reload nginx"
Nice=10
IOSchedulingClass=idle
ProtectSystem=full
ProtectHome=yes
NoNewPrivileges=yes
TimeoutStartSec=15min
# /etc/systemd/system/certbot-renew.timer
[Unit]
Description=Twice-daily certbot renewal
[Timer]
OnCalendar=*-*-* 03,15:12:00
RandomizedDelaySec=30m
Persistent=true
Unit=certbot-renew.service
[Install]
WantedBy=timers.target
systemd-analyze verify /etc/systemd/system/certbot-renew.*
systemctl enable --now certbot-renew.timer
systemctl list-timers certbot-renew.timer
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Unit goes activating (start) → failed immediately |
Wrong Type=, or ExecStart exited with non-zero on the first line |
journalctl -u <unit> -b, verify with systemd-analyze verify, pick the correct Type= |
Unit stays activating until TimeoutStartSec |
Type=notify set but the app never calls sd_notify |
Switch to Type=simple/exec, or instrument the app; NotifyAccess=main |
| Unit crash-loops without cooldown | Missing Restart=/RestartSec=/StartLimit* |
Add Restart=on-failure + RestartSec=3s; set burst limits |
| Service works after manual start, fails at boot | Missing After=network-online.target + Wants=network-online.target |
Add both; enable NetworkManager-wait-online if needed |
| Drop-in appears ignored | Missing daemon-reload, or wrong file extension (must end in .conf) |
systemctl daemon-reload && systemctl restart <unit>; verify with systemctl cat <unit> |
| Hardening broke the service | ProtectSystem=strict blocked a write, or SystemCallFilter killed on an unexpected call |
Check journalctl -u <unit> -b -p err, add ReadWritePaths= or broaden the filter, re-test |
EnvironmentFile values "not working" |
Shell-expansion expected but systemd does not expand $FOO |
Pre-render the file or move expansion into the app |
| Timer never fires | OnCalendar= typo; host clock wrong; timer not enabled |
systemd-analyze calendar "expression"; chronyc tracking; systemctl enable --now <unit>.timer |
| Socket-activated service spawns once and refuses new connections | Daemon does not handle the inherited fd, or Accept=yes mismatch |
Use Accept=no with a daemon that reads LISTEN_FDS; otherwise use Accept=yes + foo@.service |
systemctl daemon-reload says no, keeps old |
Editing a generated unit (from /run/systemd/generator) |
Override with a proper unit in /etc/systemd/system/ instead |
systemctl cat <unit> (full merged content), systemctl show <unit> (effective properties), systemd-analyze verify /path/to/unit (syntax), and journalctl -u <unit> -b -o short-iso (what actually happened).
Cross-reference
- systemd & journalctl — reading logs from units authored here.
- SELinux and SELinux debugging — many hardening options interact with SELinux domains.
- cron & Timers — when to keep cron, when to move to timers.
- Bash scripting — writing clean
ExecStartwrappers. - Service troubleshooting — runbook-style triage for dead services.