Skip to main content
RunBook Academy

Docker & ContainersXXXVI Β· Automationsystemd

systemd units for Compose stacks β€” boot ordering and the races

Advanced⏱ ~26 mindockersystemd

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

Not yet marked complete on this device.

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:

Read-only / Safedocker.service
$ 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=process

Illustrative output

Three lines matter for your unit:

  • Type=notify. The daemon tells systemd it is ready only once the API is actually serving. So After=docker.service is a real guarantee, not an approximation.
  • Requires=docker.socket with -H fd://. The daemon is socket activated. The socket exists before the daemon does.
  • Delegate=yes and KillMode=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

Configuration changeinstall
systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp.service
systemctl enable --now myapp.service
systemctl status myapp.service

systemd-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:

Read-only / Safedefaults
$ systemctl show -p DefaultTimeoutStartUSec -p DefaultTimeoutStopUSec
DefaultTimeoutStartUSec=1min 30s
DefaultTimeoutStopUSec=1min 30s

On 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.

Read-only / Safelist-timers
$ systemctl list-timers --all docker-prune.timer
NEXT                        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.service

Illustrative output

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. A unit has `Requires=docker.service` but no `After=docker.service`. What is the symptom?

  2. Q2. Which directives protect a stack whose bind mount lives on a separate filesystem? Select all that apply.

  3. Q3. A `Type=oneshot` unit that runs `docker compose up -d` without `RemainAfterExit=yes` will never run its `ExecStop=` command.

  4. 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.