systemd units and the dependency graph

Ordering, requirements, jobs and generators.

Advanced16 min · lesson 7 of 21

An application that starts before the database it needs is ready, a restart that takes a second service with it, a mount unit nobody wrote: all three come from how systemd turns unit files into a dependency graph and the graph into jobs. In this lesson you build three small units and use them to see the difference between requiring a unit and being ordered after it, what "started" means for each service type, how one start request becomes a transaction of jobs, which dependencies every service gets without asking, how stops and crashes travel along the graph, and where generated and socket-activated units come from. The Linux essentials lesson on systemd (unit files, drop-ins, daemon-reload, socket-activated SSH) is assumed.

Unit types and where units come from

Everything systemd manages is a unit, and the suffix of a unit's name gives its type. Counting the units this server has loaded, by type:

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl list-units --all --plain --no-legend | awk '{print $1}' | sed 's/.*\.//' | sort | uniq -c | sort -rn
209 service 88 device 68 target 36 socket 20 timer 18 mount 12 slice 5 path 2 scope 2 automount

Services are the processes systemd supervises. Devices are kernel devices reported by udev, such as disks, which other units can wait for. Targets are named synchronisation points such as multi-user.target that group other units and run nothing themselves. Mounts, automounts and swap describe filesystems and swap space, sockets and timers start services on a connection or at a time, and paths start them when a file appears. Slices are the nodes of the cgroup tree, the subject of the resource-control lesson, and scopes hold processes systemd tracks but did not start itself, such as login sessions. --all includes inactive units and names that other units merely refer to, which is why there are over two hundred services.

deploy@web01 · Ubuntu 26.04 LTS
$ systemd-analyze unit-paths
/etc/systemd/system.control /run/systemd/system.control /run/systemd/transient /run/systemd/generator.early /etc/systemd/system /etc/systemd/system.attached /run/systemd/system /run/systemd/system.attached /run/systemd/generator /usr/local/lib/systemd/system /usr/lib/systemd/system /run/systemd/generator.late

systemd reads unit files from these directories, and a file higher in the list replaces a file of the same name further down. /etc/systemd/system (yours) beats /run/systemd/system (runtime, gone at reboot), which beats /usr/lib/systemd/system (packages). system.control holds settings written by systemctl set-property and transient holds units created by systemd-run, both used in the resource-control lesson. The three generator directories hold units that no person wrote.

A generator is a small program that systemd runs early in boot and again at every daemon-reload, before it loads any unit. Each one translates some other configuration into unit files. The best known is systemd-fstab-generator, which turns /etc/fstab into mount units:

deploy@web01 · Ubuntu 26.04 LTS
$ grep -E '^LABEL=BOOT' /etc/fstab
LABEL=BOOT /boot ext4 defaults 0 2
$ systemctl cat boot.mount
# /run/systemd/generator/boot.mount # Automatically generated by systemd-fstab-generator [Unit] Documentation=man:fstab(5) man:systemd-fstab-generator(8) SourcePath=/etc/fstab Before=local-fs.target Requires=systemd-fsck@dev-disk-by\x2dlabel-BOOT.service After=systemd-fsck@dev-disk-by\x2dlabel-BOOT.service After=blockdev@dev-disk-by\x2dlabel-BOOT.target [Mount] What=/dev/disk/by-label/BOOT Where=/boot Type=ext4

systemctl cat names the file it read: /run/systemd/generator/boot.mount, written from the fstab line, with SourcePath= pointing back to it. The generator added dependencies nobody typed: the filesystem check of that device must finish first (Requires= and After= on systemd-fsck@...), and the mount must be done before local-fs.target. Because the unit is generated, an edit to fstab changes nothing in systemd until sudo systemctl daemon-reload runs the generators again; mount prints a hint when you forget.

deploy@web01 · Ubuntu 26.04 LTS
$ ls /usr/lib/systemd/system-generators
… netplan … sshd-socket-generator … systemd-fstab-generator … systemd-ssh-generator … systemd-sysv-generator …

Other generators read the kernel command line, /etc/crypttab, netplan's network YAML (on Ubuntu), leftover SysV init scripts (systemd-sysv-generator) and, on Ubuntu, the SSH server's port settings (sshd-socket-generator, in the last section). systemd-ssh-generator adds SSH sockets on a virtual machine's vsock device, which is why the lab VM has an sshd-vsock.socket that a physical server does not.

Requiring is not ordering

Unit files express two independent relations. Requirement directives say which units a start pulls in: Wants= pulls one in and ignores its failure, Requires= pulls one in and takes its failure seriously. Ordering directives, After= and Before=, say which of two units that are both starting waits for the other. Neither implies the other; without ordering, systemd.unit(5) says, both units start at the same time. Two lab units make that visible. Create each file below as root, for example with sudoedit, and run sudo systemctl daemon-reload after writing them. The first stands in for a database that needs five seconds before it can take connections.

/etc/systemd/system/sd-units-db.service
[Unit]
Description=Lab database (ready after five seconds)
[Service]
Type=notify
NotifyAccess=all
RuntimeDirectory=sd-units-db
ExecStart=/bin/sh -c 'sleep 5; touch /run/sd-units-db/ready; systemd-notify --ready; exec sleep infinity'

RuntimeDirectory= creates /run/sd-units-db when the service starts and removes it when it stops, so the ready file exists only while the database is up and ready. The application refuses to run without that file. It requires the database and has no ordering line.

/etc/systemd/system/sd-units-app.service
[Unit]
Description=Lab application that needs the database
Requires=sd-units-db.service
[Service]
Type=exec
ExecStart=/bin/sh -c 'test -e /run/sd-units-db/ready || { echo "database not ready"; exit 1; }; echo "connected to the database"; exec sleep infinity'
deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl start sd-units-app
$ systemctl is-active sd-units-db sd-units-app
activating failed
$ systemctl status sd-units-app
× sd-units-app.service - Lab application that needs the database Loaded: loaded (/etc/systemd/system/sd-units-app.service; static) Active: failed (Result: exit-code) since Sun 2026-09-27 09:30:59 UTC; 47ms ago … Sep 27 09:30:59 web01 systemd[1]: Starting sd-units-app.service - Lab application that needs the database... Sep 27 09:30:59 web01 sh[248003]: database not ready Sep 27 09:30:59 web01 systemd[1]: Started sd-units-app.service - Lab application that needs the database. Sep 27 09:30:59 web01 systemd[1]: sd-units-app.service: Main process exited, code=exited, status=1/FAILURE Sep 27 09:30:59 web01 systemd[1]: sd-units-app.service: Failed with result 'exit-code'.

The start reported success, and a moment later the application had failed while the database was still activating. Both units were started at the same moment and the application lost the race: database not ready. (systemctl is-active exits 0 when any of the listed units is active, 3 when none is.) static in the Loaded line means the unit has no [Install] section, so it runs only when started by hand or pulled in by another unit. Add the ordering in a drop-in, stop the database so that both units start from nothing (the application has already failed), and try again:

deploy@web01 · Ubuntu 26.04 LTS
$ printf '[Unit]\nAfter=sd-units-db.service\n' | sudo systemctl edit --stdin sd-units-app
Successfully installed edited file '/etc/systemd/system/sd-units-app.service.d/override.conf'.
$ sudo systemctl stop sd-units-db
$ time sudo systemctl start sd-units-app
real 0m5.091s user 0m0.003s sys 0m0.010s
$ systemctl status sd-units-app --lines=3
● sd-units-app.service - Lab application that needs the database Loaded: loaded (/etc/systemd/system/sd-units-app.service; static) Drop-In: /etc/systemd/system/sd-units-app.service.d └─override.conf Active: active (running) since Sun 2026-09-27 09:31:10 UTC; 49ms ago … Sep 27 09:31:10 web01 systemd[1]: Starting sd-units-app.service - Lab application that needs the database... Sep 27 09:31:10 web01 systemd[1]: Started sd-units-app.service - Lab application that needs the database. Sep 27 09:31:10 web01 sh[248304]: connected to the database

This time the start took five seconds. systemd started the database, waited until it reported that it was ready, and only then started the application, which connected. With After= in place, Requires= is also strict about failure: if the database fails to start, the application is not started at all and its job fails with "Dependency failed". Almost every real dependency needs both lines, and both go in the unit that waits.

What each directive propagates
Pulls in on start
Wants=
the other unit failing is ignored
Requires=
with After=, its failed start stops this start
BindsTo=
as Requires=, and stops with the other unit
Stop and restart
Requires=
explicit stop or restart, never a crash
PartOf=
stop and restart only; starts nothing
BindsTo=
also when the other unit dies
Order
After=
wait until the other unit has started
Before=
the same rule, written in the other unit
Type=
of the other unit: when it counts as started
Each directive is written in the unit it affects; systemd shows the reverse relation on the other unit.

What "started" means: Type= and readiness

After= waits until the other unit has finished starting, and that unit's Type= decides when that is. The database uses Type=notify: it counts as started when it sends READY=1 to systemd, which systemd-notify --ready does after the five seconds. (NotifyAccess=all lets a child process of the service send the message; the default accepts it only from the main process.) The other types, from systemd.service(5): simple, the default when a unit has ExecStart= and no Type=, counts as started as soon as systemd has forked the process, before the program has even been executed; exec once the program has been executed, so a missing binary fails the start; forking when the process systemd launched exits and leaves its child running, the traditional Unix daemon; oneshot when the process has finished; dbus when the program has taken its bus name. Switch the database to simple and see what After= is worth then:

deploy@web01 · Ubuntu 26.04 LTS
$ printf '[Service]\nType=simple\n' | sudo systemctl edit --stdin sd-units-db sudo systemctl stop sd-units-db
Successfully installed edited file '/etc/systemd/system/sd-units-db.service.d/override.conf'.
$ sudo systemctl start sd-units-app sleep 1 systemctl is-active sd-units-db sd-units-app
active failed
$ sudo systemctl revert sd-units-db
Removed '/etc/systemd/system/sd-units-db.service.d/override.conf'. Removed '/etc/systemd/system/sd-units-db.service.d'.
$ sudo systemctl stop sd-units-db sudo systemctl reset-failed sd-units-app systemctl is-active sd-units-db sd-units-app
inactive inactive

The database was active at once, so the application started at once and failed just as it did without After=. systemctl revert deleted the drop-in and returned the unit to what its own file says. The database was still running, so the last command stops it and clears the application's failed state, and both units are inactive again. Only notify, dbus and a well-written forking daemon tell systemd that a service is ready to serve. If your program supports sd_notify, use Type=notify; if not, use Type=exec and make its clients retry, or let systemd own the socket, as the last section shows.

Jobs and transactions

systemctl start does not start anything itself. It asks the manager for a job, and the manager builds a transaction: the requested job plus a job for every unit the requirement dependencies pull in, checked for contradictions and ordering loops before any of it runs. Each job then waits for the jobs it is ordered after. With both units stopped, start the application without waiting for the result:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl start --no-block sd-units-app systemctl list-jobs
JOB UNIT TYPE STATE 41727 sd-units-db.service start running 41712 sd-units-app.service start waiting 2 jobs listed.

One request, two jobs. The database's start job is running and the application's is waiting for it, because of After=. A request that would reverse a queued job is refused if you ask for that:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl stop --job-mode=fail sd-units-app
Failed to stop sd-units-app.service: Transaction for sd-units-app.service/stop is destructive (sd-units-app.service has 'start' job queued, but 'stop' is included in transaction). See system logs and 'systemctl status sd-units-app.service' for details.

--job-mode=fail makes systemd refuse any transaction that would cancel a job already queued; this stop would have cancelled the queued start, so the transaction is "destructive". The default mode, replace, would have replaced the start with the stop. Once the jobs finish, look at the dependencies the manager actually holds for the application:

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl show sd-units-app -p Requires -p Wants -p Conflicts -p Before -p After
Requires=system.slice sd-units-db.service sysinit.target Wants= Conflicts=shutdown.target Before=shutdown.target After=system.slice basic.target systemd-journald.socket sd-units-db.service sysinit.target

The file names one dependency; the rest are default dependencies, which every service gets unless it sets DefaultDependencies=no (systemd.service(5)). It requires and is ordered after sysinit.target, so early boot (local filesystems, udev, the journal) is finished; it is ordered after basic.target; and it conflicts with, and is ordered before, shutdown.target. That conflict is how shutdown works: starting shutdown.target stops every unit that conflicts with it, in the reverse of the start order. system.slice is the cgroup the service runs in, and systemd-journald.socket is there because the service's output goes to the journal. Only units that run very early or very late, such as filesystem checks, turn default dependencies off.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl list-dependencies sd-units-app
sd-units-app.service ● ├─sd-units-db.service ● ├─system.slice ● └─sysinit.target ● ├─apparmor.service ● ├─blk-availability.service …
$ systemctl list-dependencies --reverse sd-units-db
sd-units-db.service ● └─sd-units-app.service

systemctl list-dependencies draws the requirement tree without ordering, expanding target units recursively (--all expands every unit): the database, the slice, and under sysinit.target everything early boot involves. --reverse answers the opposite question, which units pull this one in. systemd-analyze dot prints both kinds of edge in the format of the Graphviz dot program, which can draw them as a picture, with a colour per dependency type:

deploy@web01 · Ubuntu 26.04 LTS
$ systemd-analyze dot 'sd-units-*'
Color legend: black = Requires … green = After digraph systemd { … "sd-units-app.service"->"system.slice" [color="green"]; … "sd-units-app.service"->"sysinit.target" [color="green"]; … "sd-units-app.service"->"sd-units-db.service" [color="black"]; … }

Stops, restarts and crashes

Requirements also carry stops and restarts. An explicit stop or restart of a unit is passed to every unit that Requires= it: stopping the database stops the application. PartOf= does only that and pulls nothing in on start. It is the directive most often written the wrong way round: PartOf=X in unit Y means that when X is stopped or restarted, Y is too, and nothing that happens to Y affects X. A worker that belongs to the application:

/etc/systemd/system/sd-units-worker.service
[Unit]
Description=Lab worker that belongs to the application
PartOf=sd-units-app.service
After=sd-units-app.service
[Service]
Type=exec
ExecStart=/usr/bin/sleep infinity
deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl start sd-units-worker systemctl show -p ConsistsOf sd-units-app
ConsistsOf=sd-units-worker.service
$ systemctl show -p MainPID --value sd-units-worker sudo systemctl restart sd-units-app systemctl show -p MainPID --value sd-units-worker
248901 248932
$ sudo systemctl stop sd-units-worker systemctl is-active sd-units-app
active

systemd records the relation on the other side as well: the application lists the worker under ConsistsOf=, a property you cannot set directly. Restarting the application restarted the worker, which has a new main PID; stopping the worker left the application alone. What Requires= does not pass on is a unit that stops by itself. Kill the database's processes, as a crash would:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl kill --signal=KILL sd-units-db sleep 1 systemctl is-active sd-units-db sd-units-app
failed active

The database is failed and the application runs on without it. systemd.unit(5) states this: a unit that deactivates on its own, such as a service whose process exits, is not propagated to units that require it. BindsTo= is the stronger form that also stops the dependent unit when its dependency dies, and it is right for a unit that must never run without the other. An application that reconnects by itself is better left running, with Restart= on the database bringing it back.

Socket activation

The essentials lesson showed that Ubuntu starts SSH through ssh.socket. The mechanism also explains why socket-activated services need little ordering. A socket unit makes systemd create the listening socket itself:

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl list-sockets ssh.socket
LISTEN UNIT ACTIVATES 0.0.0.0:22 ssh.socket ssh.service [::]:22 ssh.socket ssh.service 2 sockets listed. Pass --all to see loaded but inactive sockets, too.
$ sudo ss -tlnp 'sport = :22'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=38978,fd=3),("systemd",pid=1,fd=257)) LISTEN 0 4096 [::]:22 [::]:* users:(("sshd",pid=38978,fd=4),("systemd",pid=1,fd=258))

Each listening socket is held by two processes: systemd (PID 1), which created it, and sshd, which received it as an open file descriptor when ssh.service started. Accept=no in the socket unit means one service instance receives the listening socket and accepts all connections itself. For a listening socket, Send-Q shows the backlog limit, 4096: a client that connects while the service is still starting waits in that queue instead of being refused.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl show basic.target -p After
After=paths.target systemd-pcrphase-sysinit.service sockets.target sysinit.target tmp.mount -.mount systemd-ask-password-plymouth.path slices.target

Socket units are started by sockets.target, basic.target is ordered after sockets.target, and every ordinary service is ordered after basic.target by its default dependencies. So every socket-activated server is already listening before any ordinary service starts, and its clients need no After= on it. On Ubuntu, sshd-socket-generator copies a non-default Port or ListenAddress from the SSH server configuration into a drop-in for ssh.socket, so a port change needs sudo systemctl daemon-reload and sudo systemctl restart ssh.socket (Ubuntu's README.Debian for openssh-server says so). RHEL 10 runs sshd.service as an ordinary service that opens its own socket.

Try this

Make the application stop when the database dies. Add a second drop-in without touching the first: printf '[Unit]\nBindsTo=sd-units-db.service\n' | sudo systemctl edit --drop-in=bindsto --stdin sd-units-app, then sudo systemctl start sd-units-app, which also starts the database again. Kill the database with sudo systemctl kill --signal=KILL sd-units-db and predict systemctl is-active sd-units-db sd-units-app before you run it: expect failed and inactive. Clean up with sudo systemctl stop sd-units-worker sd-units-app and sudo systemctl reset-failed sd-units-db, delete the three unit files and the sd-units-app.service.d directory from /etc/systemd/system, and run sudo systemctl daemon-reload.

Takeaway

Write every dependency as a requirement plus an ordering in the unit that waits, and make sure the unit it waits for has a Type= that means ready. When a start, stop or crash travels the graph in a way you did not expect, systemctl show -p Requires -p After -p ConsistsOf shows the dependencies systemd is actually using.

Quick check
01api.service has Requires=redis.service and After=redis.service. redis.service is Type=simple. After some reboots the API logs "connection refused" to Redis. What is the cause?
Incorrect — Requirement dependencies pull a unit into the transaction whether or not it is enabled; enablement only adds boot links.
Correct — With Type=simple the start is complete at fork(). After= waits exactly that long, which can be before Redis opens its port.
Incorrect — After= in one unit and Before= in the other are the same ordering; either one is enough.
Incorrect — Wants= and Requires= differ in how a failure is treated. Neither of them orders anything.
02Every restart of nginx.service should also restart log-shipper.service, but starting nginx must not start log-shipper. Which line achieves that?
Incorrect — That is the reverse direction: nginx would be stopped and restarted whenever log-shipper is.
Incorrect — This starts log-shipper whenever nginx starts and passes log-shipper's stops to nginx, the opposite of what was asked.
Incorrect — Wants= pulls log-shipper in on start and passes on no stop or restart at all.
Correct — PartOf= is written in the unit that follows. systemctl show -p ConsistsOf nginx.service then lists log-shipper.
03PostgreSQL crashes with a segmentation fault and postgresql.service shows failed. app.service, with Requires= and After= on it, stays active and logs connection errors. Why did systemd leave it running?
Correct — A dependency that deactivates by itself is not propagated through Requires=; BindsTo= would stop app.service.
Incorrect — systemd did notice: it marked postgresql.service failed. It simply does not act on requiring units for that.
Incorrect — After= only orders jobs that exist. No stop job was created for app.service in the first place.
Incorrect — No such rule exists: Restart= on PostgreSQL changes when it comes back, not how Requires= treats its crash.

Related