Christian Lehnert — Linux, Hacking & Faith

Modern init Scripts - Writing Systemd Unit Files Like a Pro

Christian Lehnert2026-06-09~9 min read

Modern init Scripts

What "init Scripts" Means in 2026

The phrase carries decades of historical baggage. SysV init scripts
were shell scripts in /etc/init.d/ that managed daemons through a
combination of fork-and-detach gymnastics, lock files, and start- stop-daemon invocations. Upstart was the Ubuntu interregnum.
systemd has been the dominant init system across the major
distributions since 2014, and writing init scripts today
overwhelmingly means writing systemd unit files.

The historical SysV pattern is still relevant in narrow contexts:
Alpine Linux uses OpenRC, the BSDs have their own rc system, some
hardened production setups run s6 or runit, container init handling
deserves its own treatment (covered in a previous post on docker run --init). For the LAN of Debian and RHEL-family Linux that most
of us actually administer, the conversation is about systemd.

This post is about what separates a senior engineer's unit file
from a junior's. The difference is rarely the basic syntax. The
difference is the dozen directives that handle restart semantics,
dependency ordering, resource scoping, and the security hardening
surface that systemd has accumulated since version 232.

The Junior Unit File

A typical first-attempt unit file looks like this.

1[Unit]
2Description=My Service
3 
4[Service]
5ExecStart=/usr/local/bin/myservice
6 
7[Install]
8WantedBy=multi-user.target

It works. The service starts on boot, runs as root, has no restart
policy, no resource limits, no hardening, and will not survive a
crash without manual intervention. It is also the unit file I see
shipped in approximately a third of the open-source projects I run
locally.

The Senior Unit File

The same service, written by someone who has been operating Linux
systems for a decade, looks closer to this.

 1[Unit]
 2Description=My Service
 3Documentation=https://example.com/myservice/docs
 4After=network-online.target
 5Wants=network-online.target
 6ConditionPathExists=/etc/myservice/config.yaml
 7 
 8[Service]
 9Type=notify
10ExecStart=/usr/local/bin/myservice --config /etc/myservice/config.yaml
11ExecReload=/bin/kill -HUP $MAINPID
12WatchdogSec=30s
13 
14Restart=on-failure
15RestartSec=5s
16StartLimitIntervalSec=120
17StartLimitBurst=5
18 
19DynamicUser=yes
20StateDirectory=myservice
21RuntimeDirectory=myservice
22LogsDirectory=myservice
23 
24# Hardening
25NoNewPrivileges=yes
26ProtectSystem=strict
27ProtectHome=yes
28PrivateTmp=yes
29PrivateDevices=yes
30ProtectKernelTunables=yes
31ProtectKernelModules=yes
32ProtectControlGroups=yes
33RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
34RestrictNamespaces=yes
35LockPersonality=yes
36MemoryDenyWriteExecute=yes
37SystemCallArchitectures=native
38CapabilityBoundingSet=
39 
40# Resource limits
41LimitNOFILE=65536
42TasksMax=512
43 
44[Install]
45WantedBy=multi-user.target

Each section that the junior version omits represents a category of
operational problem that production systems eventually hit. The
remainder of this post is what each block actually does and why
it earns its place.

The Type= Field

Type= declares the contract between the service binary and
systemd's lifecycle tracking. Four values cover almost every real
case.

simple (the default) means the process that ExecStart= runs
is the service. systemd considers the service started as soon as
the process is forked, before any actual readiness has been
established. This is fine for daemons that have nothing to set up
before serving.

exec is simple with one improvement. systemd waits until
the binary has actually been exec()'d before considering the
service started. This catches the common bug where ExecStart=
points to a wrapper script that fails before reaching the real
binary. Use exec instead of simple for any service where the
distinction matters.

forking is the legacy daemon pattern. The process forks,
exits the parent, and the child becomes the service. systemd
tracks the child via PIDFile=. This is the most error-prone
type and should be avoided when the binary supports either of
the better alternatives.

notify is the modern preferred type for any service that
can be modified to support it. The service uses the sd_notify
protocol (or a libsystemd binding) to tell systemd when it is
actually ready. This eliminates the race condition between
service start and dependent services starting too early. Combined
with WatchdogSec=, it also gives systemd a liveness signal that
can trigger restart when the service hangs without crashing.

oneshot is for setup scripts that run, complete, and exit.
The service is considered active after completion if combined
with RemainAfterExit=yes.

The choice of Type= is not stylistic. It determines whether
dependent services start at the right time, whether failed
startups are detected promptly, and whether systemd can detect
hung processes. The senior unit file uses notify when the
service supports it and exec when it does not.

Restart Semantics

Three directives together define how systemd reacts to service
failures.

Restart=on-failure is the right default for almost every
service. It restarts on non-zero exit, on signal, on watchdog
timeout, on unclean shutdown. It does not restart when the
service exited cleanly with code 0, which is the correct behavior
for one-shots and for services explicitly stopped by the
operator.

RestartSec=5s introduces a backoff between restart attempts.
Without it, a service that crashes immediately on start will
spin in a tight loop, consuming CPU and filling the log. Five
seconds is a reasonable default; one second is too aggressive for
most workloads.

StartLimitIntervalSec=120 and StartLimitBurst=5 together
declare a circuit breaker. If the service restarts more than
five times in two minutes, systemd gives up and marks the unit
as failed until manually reset. The numbers should reflect the
actual crash dynamics of the service, but the principle is
non-negotiable. A service that cannot start should not be in a
restart loop forever.

Dependencies Done Right

The unit file declares its place in the boot sequence through a
small set of relationship directives. The two distinctions worth
internalizing are ordering versus requirement and soft
versus hard
.

After= and Before= declare ordering only. "Start me after the
network." If the dependency does not start, my service still
runs.

Wants= and Requires= declare requirement. Wants= is soft:
systemd attempts to start the dependency, but failure does not
prevent my service from running. Requires= is hard: if the
dependency fails, my service is not started.

The common idiom in the senior unit file above is After= network-online.target paired with Wants=network-online.target.
The ordering ensures my service starts after the network is
actually configured. The soft requirement ensures the network
target is pulled in if nothing else has done so. If the network
configuration legitimately fails, the dependency is documented
but does not cascade into "my service was never tried."

BindsTo= and PartOf= are the lifecycle-coupling directives.
BindsTo= makes my service stop when the dependency stops.
PartOf= makes my service restart when the dependency restarts.
Both are useful for services that genuinely cannot exist without
their backing infrastructure.

DynamicUser and the State Directories

The traditional pattern for running a service as a non-root user
is to create a system user with adduser --system, set
User= and Group= in the unit file, and manage the user's
home directory and permissions manually.

DynamicUser=yes replaces all of this. systemd allocates an
ephemeral user ID at service start, runs the service as that
user, and releases the UID when the service stops. The UID
never collides with anything. The user never persists in
/etc/passwd. The whole category of "what user should this run
as" disappears.

The complement is the directory family. StateDirectory=
creates /var/lib/<name>/ owned by the dynamic user.
RuntimeDirectory= creates /run/<name>/. LogsDirectory=
creates /var/log/<name>/. CacheDirectory= creates
/var/cache/<name>/. All are scoped to the service and visible
only to it. The service binary writes to environment variables
$STATE_DIRECTORY, $RUNTIME_DIRECTORY, etc., which systemd
populates.

The combination of DynamicUser=yes and the directory family is
the modern way to ship a service that needs persistent state
without polluting the host with a permanent user account and a
permanent directory hierarchy. The Debian project's own packaging
guidelines have been moving in this direction since 2022.

The Hardening Block

The block of directives starting with NoNewPrivileges= and
ending with CapabilityBoundingSet= is the surface that systemd
has accumulated since version 232 for sandboxing services
without requiring changes to the service binary itself. Each
directive removes a capability that the service does not need.

NoNewPrivileges=yes blocks setuid binaries from gaining
privileges. The service can never escalate beyond what it
started with.

ProtectSystem=strict makes the entire root filesystem read-only
for the service, except for explicitly declared writable paths
via ReadWritePaths= and the directory family above. Most
services do not need to write to /etc at runtime. With this
directive, they cannot.

ProtectHome=yes hides /home, /root, and /run/user from
the service. A service that compromises is also a service that
should never see user home directories.

PrivateTmp=yes gives the service its own private /tmp and
/var/tmp. Cross-service /tmp pollution is structurally
prevented.

PrivateDevices=yes hides device nodes other than /dev/null,
/dev/zero, and the standard pseudo-devices. A service that
does not need raw device access does not get it.

ProtectKernelTunables=yes, ProtectKernelModules=yes, and
ProtectControlGroups=yes block writes to /proc/sys,
kernel module operations, and cgroup hierarchy modifications
respectively.

RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX restricts the
service to the network families it actually uses. A service that
talks only over IPv4 and Unix sockets cannot create AF_PACKET
sockets to sniff traffic.

RestrictNamespaces=yes prevents the service from creating new
namespaces.

LockPersonality=yes prevents the service from changing its
execution personality (architecture emulation modes).

MemoryDenyWriteExecute=yes blocks the W^X bypass that most
modern exploit chains rely on for code injection.

SystemCallArchitectures=native restricts to the system's
native architecture syscall set.

CapabilityBoundingSet= (set to empty) drops every Linux
capability the service might have inherited.

Each directive on its own removes a small amount of attack
surface. The composition produces a service whose blast radius
under compromise is dramatically smaller than the default. The
test for whether you have hardened enough is to run systemd- analyze security <service> against the unit and read the
score. A unit file with full hardening typically scores under
2.5; an unhardened one scores 9.0 or higher.

Common Mistakes

The unit files that age worst in production share a small set of
recurring problems.

Type=forking without a PIDFile= produces unreliable lifecycle
tracking. Either get the PIDFile right or move to Type=exec or
Type=simple.

Restart=always without StartLimitIntervalSec= produces
restart loops that fill logs and consume CPU. Always pair the
restart policy with a circuit breaker.

User=root with no hardening is the default that most copied
unit files exhibit. The right question is "what is the minimum
privilege this service needs," and the right answer almost never
involves running as root.

Hardcoded paths in ExecStart= that depend on system layout. The
unit file should use absolute paths to the binary but should let
the service itself resolve config locations relative to its own
configuration.

Environment= lines that contain secrets. Use
EnvironmentFile= pointing at a 0600 file owned by root, or
better, LoadCredential= for systems supporting it.

Closing

The systemd unit file format has accumulated enormous capability
since 2014. Most of it is documented in systemd.service(5),
systemd.unit(5), systemd.exec(5), and systemd.resource- control(5). The man pages are dense, accurate, and well worth
reading once. Most unit files in production were written by
people who had not.

A senior unit file is approximately fifty lines, declares its
restart semantics explicitly, runs under a dynamic user with
properly scoped state directories, drops every capability the
service does not need, and survives both the easy case (service
crashes once) and the hard case (service is compromised and the
attacker is inside the sandbox).

A junior unit file is six lines and runs as root.

The cost of writing the senior version, once you know the
directives, is approximately twenty additional minutes per
service. The benefit is collected every time the service crashes
gracefully, every time the hardening prevents a compromise from
spreading, and every time the next operator reads the unit and
understands what the service actually does.

systemd-analyze security <service> is the test. Run it. Read
the score. The score is honest.

Tagged:
#linux #systemd #init #services #sysadmin
← Back to posts