Docker & ContainersXXXVI Β· Automationsystemd
systemd units for Compose stacks β boot ordering and the races
What you'll learn
- Write a systemd unit that manages a Compose stack correctly
- Order a unit against the Docker daemon and against filesystem mounts
- Recognise the boot races that only appear on a real reboot
- Decide between a restart policy and a systemd unit, rather than using both
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
Dockerβs restart policies already bring containers back after a reboot, so the first question is why you would involve systemd at all.
The answer is everything a restart policy cannot express: ordering against
anything that is not Docker, dependency on a filesystem being mounted, a
single command that stops the whole stack, failure surfaced through
systemctl status and the journal, and a place for the operator to look that
is the same place they look for every other service on the host.
The second question β and this is the one that produces 6am incidents β is
what a unit has to say to survive a real reboot, as opposed to the
systemctl restart you tested with.
What you are ordering against
Read the daemonβs own unit before writing anything that depends on it:
$ systemctl cat docker.service[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network-online.target nss-lookup.target docker.socket firewalld.service containerd.service time-set.target
Wants=network-online.target containerd.service
Requires=docker.socket
[Service]
Type=notify
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
TimeoutStartSec=0
Restart=always
Delegate=yes
KillMode=processIllustrative output
Three lines matter for your unit:
Type=notify. The daemon tells systemd it is ready only once the API is actually serving. SoAfter=docker.serviceis a real guarantee, not an approximation.Requires=docker.socketwith-H fd://. The daemon is socket activated. The socket exists before the daemon does.Delegate=yesandKillMode=process. Containers live in cgroups that systemd has handed over to Docker. They are not in your unitβs cgroup, which has consequences we come back to below.
A unit that works
systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp.service
systemctl enable --now myapp.service
systemctl status myapp.servicesystemd-analyze verify catches typos in directive names, which systemd
otherwise logs as a warning at load time and then ignores β a misspelled
RequresMountsFor= is a unit that has no mount dependency at all and looks
completely normal.
The four races
Each of these works when you test it with systemctl restart and fails on a
cold boot.
1. Requires= without After=
Without After=, systemd is free to start your unit in parallel with
docker.service. The compose command runs before the API is up:
Aug 11 06:14:02 host docker[1841]: Cannot connect to the Docker daemon at
unix:///var/run/docker.sock. Is the docker daemon running?
Aug 11 06:14:02 host systemd[1]: myapp.service: Main process exited, code=exited, status=1
The confusing part is that it is intermittent. On a host with a fast disk the daemon wins the race and everything looks fine for months.
2. Bind mounts on a filesystem that is not mounted yet
This is the expensive one.
3. Type=oneshot without RemainAfterExit=yes
docker compose up -d returns as soon as the containers are created.
systemd sees the main process exit successfully and, for a oneshot unit
without RemainAfterExit, marks the unit inactive (dead).
Consequences: systemctl status myapp shows inactive while the stack is
running, ExecStop= never runs because there is nothing to stop, and
systemctl stop myapp therefore leaves every container up. Operators
conclude that systemd βis not managing itβ and start running compose by hand
alongside the unit, which is how a host ends up with two sources of truth.
4. Start and stop timeouts
TimeoutStartSec defaults to 90 seconds and TimeoutStopSec to 90 seconds:
$ systemctl show -p DefaultTimeoutStartUSec -p DefaultTimeoutStopUSecDefaultTimeoutStartUSec=1min 30s
DefaultTimeoutStopUSec=1min 30sOn the first boot after an image change, docker compose up -d --wait has to
pull. A 900 MB image on a slow link takes longer than 90 seconds, systemd
kills the start job, and with Restart=on-failure the unit loops β pulling,
being killed, pulling again β saturating the link and never finishing.
The stop side is worse, because it is silent. docker compose down stops
each container with its configured grace period. Four containers at 60
seconds each can exceed 90 seconds, systemd SIGKILLs the compose process
mid-teardown, and you are left with half the stack running, orphaned
networks, and a unit that reports it stopped cleanly.
Size both timeouts from the stack, not from the default.
Do not manage the same containers twice
Timers, not cron
For periodic Docker maintenance, a systemd timer beats a crontab: it logs to
the journal, it has Persistent=true for missed runs, and systemctl list-timers shows the whole schedule on the host in one place.
# /etc/systemd/system/docker-prune.service
[Unit]
Description=Prune unused Docker build cache
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/usr/bin/docker builder prune --force --filter 'until=168h'
# /etc/systemd/system/docker-prune.timer
[Unit]
Description=Weekly Docker build cache prune
[Timer]
OnCalendar=Sun 03:00
RandomizedDelaySec=1800
Persistent=true
[Install]
WantedBy=timers.target
RandomizedDelaySec matters as soon as you have more than one host: without
it, every host in the estate prunes at exactly 03:00 and hits the registry or
the storage backend simultaneously.
$ systemctl list-timers --all docker-prune.timerNEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-08-16 03:11:47 UTC 4 days Sun 2026-08-09 03:23:05 UTC 2 days docker-prune.timer docker-prune.serviceIllustrative output
Knowledge check
Knowledge check Β· 4 questions
Q1. A unit has `Requires=docker.service` but no `After=docker.service`. What is the symptom?
Q2. Which directives protect a stack whose bind mount lives on a separate filesystem? Select all that apply.
Q3. A `Type=oneshot` unit that runs `docker compose up -d` without `RemainAfterExit=yes` will never run its `ExecStop=` command.
Q4. Why does `systemctl stop` on a `Type=simple` unit running `docker compose up` in the foreground leave the containers running?
Passing score: 75%. Answers are checked in this browser.