systemd Service Units
A service unit (
.service) is the systemd unit type that supervises a process — a daemon, a one-shot setup step, a worker. It is the workhorse of the system: when you runsystemctl start nginx, systemd forks and execs the binary inExecStart=, places it in its own cgroup, tracks its main PID, and — for free, without any code in the daemon — restarts it on failure according toRestart=, captures its stdout/stderr into the journal, and reports its health tosystemctl status(systemd.service(5)). The most consequential and most misunderstood setting isType=, which tells systemd how to decide the service has finished starting up — the readiness contract between the daemon and the manager. GetType=right and dependent units start at exactly the right moment; get it wrong and you get races, false “active” states, and reload bugs.
This note assumes the unit basics — file format, [Unit]/[Install] sections, the search path, drop-ins, templates — and drills into the .service-specific [Service] section.
Mental Model
A service is a small supervision state machine. systemd forks the process, decides when it counts as started (the Type= question), then watches it: if it exits, the Restart= policy decides whether to relaunch it, after a RestartSec= cool-down. The pivotal subtlety is that “started” is not the same as “the process exists” — for a network daemon you usually want “started” to mean “ready to accept connections,” and the different Type= values are precisely different answers to when systemd is allowed to start the units that depend on this one.
flowchart TB START["systemctl start foo.service"] --> PRE["run ExecStartPre=<br/>(serially, must succeed)"] PRE --> FORK["fork + exec ExecStart="] FORK --> RDY{"Type= readiness rule:<br/>when is it 'started'?"} RDY -->|"simple: after fork()"| ACT["active<br/>(dependents may start)"] RDY -->|"exec: after execve()"| ACT RDY -->|"forking: parent exits"| ACT RDY -->|"oneshot: process exits"| ACT RDY -->|"notify: READY=1 received"| ACT RDY -->|"dbus: bus name acquired"| ACT ACT --> POST["run ExecStartPost="] POST --> WATCH["watch main process"] WATCH -->|"exits"| POLICY{"Restart= policy<br/>matches exit?"} POLICY -->|"yes"| WAIT["wait RestartSec=,<br/>then restart"] WAIT --> FORK POLICY -->|"no"| DEAD["inactive / failed"]
The service lifecycle and the role of Type=. What it shows: ExecStartPre= runs first, then the main process is forked; the Type= rule decides the moment the unit becomes active (and thus the moment its dependents are allowed to start); after it exits, Restart= decides whether to relaunch after RestartSec=. The insight to take: every Type= is a different definition of “ready,” and choosing it correctly is what makes ordering dependencies actually mean “after it can serve,” not merely “after the process was spawned.”
Type= — The Readiness Contract
Type= is the heart of a service unit because it answers: at what instant does systemd consider the unit active, and therefore release the units ordered After= it? (systemd.service(5)).
simple(the default whenExecStart=is set withoutType=orBusName=) — “the service manager will consider the unit started immediately after the main service process has been forked off (i.e. immediately afterfork(), and before … the new process has calledexecve()).” This is the cheapest and most dangerous default: the unit is “active” before the binary has even begun running, so a daemon that needs a second to open its listening socket is reported ready before it can serve. Dependents ordered after it may race ahead.exec— “the service manager will consider the unit started immediately after the main service binary has been executed.” Unlikesimple, it waits forexecve()to succeed, so a missing executable or a badUser=makessystemctl startactually report failure. It still does not wait for the daemon to be ready, only for it to start running. Preferexecoversimplewhen you want start failures surfaced but the daemon has no readiness protocol.forking— for classic Unix daemons that double-fork and exit the parent: “the manager will consider the unit started immediately after the binary that forked off by the manager exits.” You should setPIDFile=so systemd can identify which child is the real main process. The manual is blunt: “The use of this type is discouraged, usenotify,notify-reload, ordbusinstead.” Forking exists for compatibility with daemons written before systemd; it is racy because systemd has to track the main process through a fork it did not control.oneshot— for a command that runs to completion and exits (a migration, aiptablessetup step): “the service manager will consider the unit up after the main process exits. It will then start follow-up units.”oneshotis the only type whereExecStart=may be specified multiple times (or zero times ifRemainAfterExit=yesand anExecStop=is given). It is the default when neitherType=norExecStart=is set.dbus— “units of this type must have theBusName=specified and the service manager will consider the unit up when the specified bus name has been acquired.” Readiness is signaled by the daemon registering its well-known D-Bus name — a precise, race-free readiness signal for D-Bus services.notify— “it is expected that the service sends aREADY=1notification message via sd_notify(3) … when it has finished starting up. systemd will proceed with starting follow-up units after this notification message has been sent.” This is the gold standard: the daemon itself tells systemd the exact instant it is ready to serve.notify-reload— likenotify, but adds a clean reload protocol: on reload, systemd sendsSIGHUP(configurable viaReloadSignal=) and waits for the daemon to sendRELOADING=1thenREADY=1again. Lets a config reload be synchronous and ordered.idle— execution “is delayed until all active jobs are dispatched,” with a 5-second cap. Purely a cosmetic aid so a service’s console output does not interleave with boot messages; never use it for ordering correctness.
Readiness Protocols — Why notify Beats forking
The reason Type=notify is preferred over Type=forking is that the daemon, not systemd, knows when it is ready — and notify lets it say so exactly. With forking, systemd infers readiness from “the parent exited,” which is a convention, not a guarantee: a daemon might fork early and still be loading config when the parent dies, so the unit goes “active” before it can serve. With notify, the daemon opens its sockets, loads its config, and then calls sd_notify(0, "READY=1") (sd_notify(3)) — and only at that call does systemd mark the unit active and release dependents. READY=1 “Tells the service manager that service startup is finished … This is only used by systemd if the service definition file has Type=notify or Type=notify-reload set.”
The mechanism is delightfully simple: systemd passes the daemon a $NOTIFY_SOCKET environment variable naming an AF_UNIX datagram socket; sd_notify writes newline-separated key=value strings to it. Beyond READY=1, the protocol carries STATUS= (a human status line shown in systemctl status), RELOADING=1 + MONOTONIC_USEC= (the reload handshake), STOPPING=1, WATCHDOG=1 (the keep-alive ping, below), and MAINPID= (tell systemd a different PID is the main process). If $NOTIFY_SOCKET is unset, sd_notify is a no-op and returns 0 — so a notify-aware binary still runs fine when launched outside systemd. systemd controls who may send notifications via NotifyAccess= (none/main/exec/all); when Type=notify is set but NotifyAccess= is not, “it will be implicitly set to main.”
forking is not wrong — it is the compatibility path for pre-systemd daemons — but it is strictly inferior whenever you control the source. The modern advice (echoed for Type=dbus over forking in Poettering’s admin series) is: if the daemon can register a D-Bus name use dbus; if you can add three lines of sd_notify use notify; only fall back to forking for software you cannot modify.
The Exec* Commands
The [Service] section sequences several command families (systemd.service(5)):
ExecStart=— “Commands that are executed when this service is started.” Exactly one command is required, except forType=oneshot, where multipleExecStart=lines run serially. “UnlessType=forkingis set, the process started via this command line will be considered the main process of the daemon.”ExecStartPre=/ExecStartPost=— commands run before and afterExecStart=. Multiple lines allowed for any type, executed “one after the other, serially.”ExecStart=runs only after all non---prefixedExecStartPre=commands “exit successfully.” Critically, “ExecStartPre=may not be used to start long-running processes. All processes forked off by processes invoked viaExecStartPre=will be killed before the next service process is run” —ExecStartPre=is for preparation, not for launching helpers.ExecStop=/ExecStopPost=—ExecStop=runs to stop the service and is “only executed when the service started successfully first.”ExecStopPost=runs unconditionally after the service stops, “including cases where … the service exited unexpectedly” and “when a service failed to start up correctly” — the right place for cleanup that must always happen. It receives$SERVICE_RESULT,$EXIT_CODE,$EXIT_STATUS.ExecReload=— the command to reload config in place;$MAINPIDis exported so you cankill -HUP $MAINPID.
Each command path supports prefixes: a leading - makes a non-zero exit “considered equivalent to success” (ignore failure of an optional step); @ passes the next token as argv[0]; + runs the command with full privileges bypassing User=/sandboxing; : disables environment-variable substitution.
RemainAfterExit= and PIDFile=
RemainAfterExit= “specifies whether the service shall be considered active even when all its processes exited. Defaults to no.” It is the companion to oneshot: a firewall-setup service runs iptables, exits, and would normally go inactive — but with RemainAfterExit=yes it stays active, so “invoking systemctl start on that unit again will cause no action to be taken” (the rules are already in place) and so the unit can be a meaningful After= ordering target.
PIDFile= is the forking companion: a path (typically under /run/) from which “the service manager will read the PID of the main process … after start-up.” systemd never writes it — the daemon does — though it removes a stale one on shutdown. The manual’s verdict: “PID files should be avoided in modern projects. Use Type=notify, Type=notify-reload or Type=simple where possible.”
Restart= — Supervision For Free
This is the headline feature: systemd is a process supervisor, so a crashed service can be relaunched with no external watchdog, no cron respawn loop, no systemctl cron job. Restart= selects the policy (systemd.service(5)):
no(default) — never restart.on-success— restart only on a clean exit: exit code 0, or (for non-oneshot) one ofSIGHUP/SIGINT/SIGTERM/SIGPIPE, or a status listed inSuccessExitStatus=.on-failure— restart “when the process exits with a non-zero exit code, is terminated by a signal (including on core dump, but excluding the aforementioned four signals), when an operation … times out, and when the configured watchdog timeout is triggered.” This is the usual choice for a long-running daemon.on-abnormal— restart on signal, timeout, or watchdog, but not on a plain non-zero exit (so a deliberateexit(1)is respected, a crash is not).on-abort— only on an uncaught signal not declared a clean exit.on-watchdog— only when the watchdog timeout expires.always— “restarted regardless of whether it exited cleanly or not.” Note “Type=oneshotservices will never be restarted on a clean exit status, i.e.alwaysandon-successare rejected for them.”
Timing is governed by RestartSec= — “the time to sleep before restarting … Defaults to 100ms” — so a crash-looping service does not spin the CPU. For backoff, RestartSteps= and RestartMaxDelaySec= add exponential growth: with RestartSec=10s, RestartSteps=4, RestartMaxDelaySec=160s, successive delays become 10s, 20s, 40s, 80s, 160s, then hold at 160s. Independently, StartLimitIntervalSec=/StartLimitBurst= (in [Unit]) cap how many restarts are allowed in a window before systemd gives up and marks the unit failed — preventing an infinite crash loop from masquerading as a healthy service.
Worked Example — A Notify-Style Service
[Unit]
Description=Inventory API
Documentation=https://example.com/inventory
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=notify
NotifyAccess=main
ExecStartPre=/usr/bin/inventory-migrate --check
ExecStart=/usr/bin/inventory-api --listen 0.0.0.0:8080
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2s
RestartSteps=5
RestartMaxDelaySec=60s
WatchdogSec=30s
User=inventory
Group=inventory
[Install]
WantedBy=multi-user.targetReading it: [Unit] orders the service after the network and database and requires Postgres (paired After=+Requires=, the correct idiom per systemd Dependencies and Ordering). Type=notify means systemd waits for the binary to call sd_notify(0, "READY=1") before declaring the unit active — so anything ordered after it truly starts after the API can serve. NotifyAccess=main allows only the main process to send notifications. ExecStartPre= runs a migration check and must succeed (no - prefix) before the API launches; it must be short-lived, not a server. ExecStart= is the long-running main process. ExecReload=/bin/kill -HUP $MAINPID reloads config on systemctl reload. Restart=on-failure relaunches on crash/timeout/watchdog but not on a clean shutdown; the first retry waits RestartSec=2s, then backs off exponentially over RestartSteps=5 toward RestartMaxDelaySec=60s. WatchdogSec=30s arms the watchdog: the daemon must send sd_notify(0, "WATCHDOG=1") at least every 30 seconds, or systemd “is placed in a failed state and it will be terminated with SIGABRT” — combined with Restart=on-failure this means a hung (not crashed) daemon is detected and restarted. User=/Group= drop privileges. [Install]’s WantedBy=multi-user.target makes systemctl enable wire it into the normal boot.
A minimal oneshot contrasts sharply:
[Unit]
Description=Apply static firewall rules
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/iptables-restore /etc/iptables/rules.v4
ExecStart=/usr/sbin/ip6tables-restore /etc/iptables/rules.v6
[Install]
WantedBy=multi-user.targetHere two ExecStart= lines (legal only for oneshot) run serially; RemainAfterExit=yes keeps the unit active after they exit so the rules count as “applied” and the unit is a valid After= target. No Restart= makes sense — there is no process to supervise.
Failure Modes and Common Misunderstandings
Type=simplerace. The classic bug: a daemon underType=simpleis “active” the instant it is forked, so a service orderedAfter=it starts before it can serve. Symptom: intermittent “connection refused” at boot. Fix:Type=notify(best),Type=exec(at least catches exec failures), or a readiness-aware client.forkingwith no/ wrongPIDFile=. systemd cannot find the main process, sosystemctl statusshows the wrong PID,MainPID=0, or premature “inactive.” Symptom:systemctl stopfails to actually stop the daemon. Fix: correctPIDFile=, or migrate offforking.ExecStartPre=starts a server. It gets killed beforeExecStart=runs (“All processes forked off by …ExecStartPre=will be killed”). Put long-running helpers in their own unit, ordered before this one.Restart=alwaysmasks a crash loop. WithoutStartLimitBurst=/StartLimitIntervalSec=, a service that crashes on startup restarts forever;systemctl statusflickers active/failed. Set start limits so systemd gives up and the failure is visible.- Reload that isn’t.
ExecReload=runs a command but the daemon doesn’t actually re-read config, or underType=notify-reloadthe daemon never sendsRELOADING=1/READY=1, sosystemctl reloadhangs until timeout. Verify the daemon honors the reload signal. - Watchdog kills a healthy-but-slow service.
WatchdogSec=too tight for a service that legitimately blocks (e.g. during GC or a long request) triggersSIGABRT. Tune it to the worst-case ping interval, or omit it for services without a watchdog loop.
Alternatives and When to Choose Them
Before systemd, supervision came from SysV init scripts (no supervision at all — a crashed daemon stayed dead until you noticed), or external supervisors like daemontools, runit, supervisord, or monit. systemd folds supervision, logging, socket activation, and resource control into the init system itself, so for a daemon on a systemd host a .service unit is almost always the right tool. The cases for an external supervisor inside a service are narrow: an application that wants to manage its own worker pool (then run the master under .service and let it fork workers), or a container whose runtime is the supervisor. Within systemd, the choice is between types, not tools: notify if you control the source, dbus for D-Bus daemons, oneshot for setup steps, simple/exec for trivial foreground programs, and forking only as a legacy bridge. See The Init System Comparison for the broader landscape.
Production Notes
In practice the rules of thumb are: make services run in the foreground and use Type=exec or Type=notify — fighting a daemon’s own daemonization (--daemon, double-fork) under Type=forking is the most common source of flaky units. Always pair After= with the matching Requires=/Wants= (systemd Dependencies and Ordering) so ordering implies the dependency is actually present. Use Restart=on-failure plus start limits as a default supervision posture, escalating to WatchdogSec= only for daemons with a real liveness loop. Because every service runs in its own cgroup, systemctl status and systemd-cgls show the entire process tree — including children the daemon forked — which is how systemctl stop reliably kills the whole tree where a PID-file approach would leak children. As of systemd 261 (June 2026), the [Service] directives described here — the eight Type= values, the Restart= policy set, and the sd_notify protocol — are the long-stable core; recent releases add tooling and sandboxing options around this model rather than changing it (systemd 261 context).
Uncertain
Verify: that the
Type=value set, theRestart=value set, and theRestartSteps=/RestartMaxDelaySec=exponential-backoff feature are present and unchanged in the exact systemd version on a given target host. Reason: the canonical freedesktop systemd.service(5) page returned HTTP 403 during research, so wording here is from the man7.org mirror tracking upstream “latest,” andRestartSteps=/RestartMaxDelaySec=are relatively recent additions whose availability depends on the installed version. To resolve: runman systemd.serviceon the target host and check its version withsystemctl --version. uncertain
See Also
- systemd Units — the universal unit abstraction this note specializes; file format,
[Unit]/[Install], drop-ins, templates - systemd Socket Activation —
.socketunits that start a.servicelazily on first connection - systemd and cgroup Integration — how every service is confined to its own cgroup for resource control and reliable kill
- systemd Dependencies and Ordering —
Wants=/Requires=vsBefore=/After=, the correct pairing for service ordering - The systemd Journal — where a service’s stdout/stderr and structured logs are captured
- Linux Boot and Init MOC — the parent map of the boot-and-init story