Homelab
Proof

U1 / Docker foundations

Docker restart policies after a reboot, tested

Tested on Docker Engine 29.9.2

Test result

Use unless-stopped for services that should survive reboots: it returned in 3 of 3 reboots and stayed down after a manual docker stop. always also returned after a manual stop, no never returned, and on-failure came back only when the process had been killed (exit 137), not when it had exited 0.

Line drawing: a power button connected by cables to four small containers, some lit and some dark.

Pick the policy by what should happen to a container you stopped on purpose. That is the only case where the policies differ for a service that exits cleanly, and this test checked it with real reboots rather than by reading the table in the docs.

Matrix

Each policy was started twice: once left running, once stopped with docker stop. Two kinds of process were used, because the exit code decides what on-failure does: a script that exits 0 on SIGTERM, and sleep as PID 1, which ignores SIGTERM and is killed after the stop timeout (exit 137). The state was recorded after systemctl restart docker and after each of three VM reboots. Before every reboot the script started the “running” containers and stopped the “stopped by hand” ones again, so each reboot is an independent test from the same starting layout (raw/2-before-reboot-N.tsv). An earlier version of the script reset the layout only once, which made reboots 2 and 3 repeat the state left by reboot 1 for some cells; it was rerun.

Which containers are running after a daemon restart and after a VM reboot, by restart policy
Process in the containerContainer state beforePolicyAfter `systemctl restart docker` (15 s)After reboot 1 / 2 / 3 (last sample, 27 s after ssh came up)
exits 0 on SIGTERMRunning when stoppednoexitedexited/exited/exited
exits 0 on SIGTERMRunning when stoppedalwaysrunningrunning/running/running
exits 0 on SIGTERMRunning when stoppedon-failureexitedexited/exited/exited
exits 0 on SIGTERMRunning when stoppedunless-stoppedrunningrunning/running/running
exits 0 on SIGTERMStopped by hand (docker stop) firstnoexitedexited/exited/exited
exits 0 on SIGTERMStopped by hand (docker stop) firstalwaysrunningrunning/running/running
exits 0 on SIGTERMStopped by hand (docker stop) firston-failureexitedexited/exited/exited
exits 0 on SIGTERMStopped by hand (docker stop) firstunless-stoppedexitedexited/exited/exited
`sleep` as PID 1 (killed, exit 137)Running when stoppednoexitedexited/exited/exited
`sleep` as PID 1 (killed, exit 137)Running when stoppedalwaysrunningrunning/running/running
`sleep` as PID 1 (killed, exit 137)Running when stoppedon-failurerunningrunning/running/running
`sleep` as PID 1 (killed, exit 137)Running when stoppedunless-stoppedrunningrunning/running/running
`sleep` as PID 1 (killed, exit 137)Stopped by hand (docker stop) firstnoexitedexited/exited/exited
`sleep` as PID 1 (killed, exit 137)Stopped by hand (docker stop) firstalwaysrunningrunning/running/running
`sleep` as PID 1 (killed, exit 137)Stopped by hand (docker stop) firston-failurerunningrunning/running/running
`sleep` as PID 1 (killed, exit 137)Stopped by hand (docker stop) firstunless-stoppedexitedexited/exited/exited

Alpine 3.22 `sleep 100000`, Docker data root on the VM disk, VM 4 vCPU, 3 GB. The run/stopped layout was reset before every reboot (see raw/2-before-reboot-N.tsv). Reboot states are the last of ten samples taken 3 s apart once ssh answered.

What the table says:

  • no: never returned, in any case.
  • always: returned every time, including the container that was stopped by hand. This matches the docs: a manually stopped always container restarts when the daemon restarts.
  • unless-stopped: returned when it was running, stayed stopped when it was stopped by hand, in both process types.
  • on-failure: the surprise. A process that exited 0 on SIGTERM stayed down after the daemon restart and all three reboots, running or stopped. The sleep container, which Docker had to kill, came back every time, even the one that had been stopped by hand. The docs say on-failure “doesn’t restart the container if the daemon restarts”; in this test a container that had exited with a non-zero code did come back. The likely reason is the exit code recorded when the daemon stopped it, but the test did not isolate that.

A one-shot container that exits 0 on its own under on-failure stayed exited (oneshot-ok-on-failure, in raw/).

How soon after boot

VM uptime at the first sample in which each started container was running (seconds since boot)
ContainerReboot 1, 2, 3
g-run-no-, -, -
g-run-always10 s, 10 s, 10 s
g-run-on-failure-, -, -
g-run-unless-stopped10 s, 10 s, 10 s

Sampled every 3 s plus ssh round trips, so each value has a resolution of several seconds.

The test sampled every 3 s once SSH answered, and in every reboot the containers were already running at the first sample, so the table gives an upper bound on boot-to-running time of about 10 s of VM uptime, not a precise figure. It also shows on-failure and no never reaching running with the graceful container.

Crash loops and back-off

Three identical containers per scenario ran for 270 s. Each start printed the VM uptime, so the gaps below come from the containers’ own timestamps.

Crash-loop timing: seconds between consecutive container starts (each start prints its timestamp)
ScenarioReplicaStarts loggedGaps between starts (s)Final state
exit 1 immediately, --restart alwaysreplica 113 starts0.48, 0.58, 0.75, 1.31, 2.13, 3.46, 6.66, 13.07, 25.81, 51.44, 60.24, 60.27RestartCount=13 State=restarting ExitCode=1
replica 213 starts0.56, 0.74, 0.8, 1.19, 1.9, 3.45, 6.63, 13.07, 25.84, 51.44, 60.19, 60.24RestartCount=13 State=restarting ExitCode=1
replica 313 starts0.5, 0.77, 0.7, 1.08, 1.85, 3.43, 6.62, 13, 25.83, 51.45, 60.16, 60.21RestartCount=13 State=restarting ExitCode=1
exit 1 immediately, --restart on-failure:3replica 14 starts0.54, 0.56, 0.77RestartCount=3 State=exited ExitCode=1
replica 24 starts0.72, 0.57, 0.85RestartCount=3 State=exited ExitCode=1
replica 34 starts0.76, 0.47, 0.66RestartCount=3 State=exited ExitCode=1
runs 12 s then exit 1, --restart alwaysreplica 123 starts12.33, 12.34, 12.4, 12.36, 12.35, 12.37, 12.39, 12.31, 12.39, 12.32, 12.39, 12.34, 12.35, 12.3, 12.4, 12.31, 12.37, 12.3, 12.34, 12.28, 12.36, 12.28RestartCount=22 State=running ExitCode=0
replica 223 starts12.38, 12.31, 12.32, 12.32, 12.35, 12.34, 12.34, 12.38, 12.34, 12.39, 12.35, 12.3, 12.36, 12.33, 12.39, 12.31, 12.35, 12.31, 12.35, 12.36, 12.35, 12.35RestartCount=22 State=running ExitCode=0
replica 323 starts12.31, 12.32, 12.32, 12.41, 12.41, 12.37, 12.37, 12.38, 12.34, 12.4, 12.35, 12.32, 12.38, 12.34, 12.34, 12.33, 12.35, 12.37, 12.36, 12.36, 12.33, 12.42RestartCount=22 State=running ExitCode=0

Three identical containers per scenario ran concurrently for 270 s. The gap includes the container's own run time plus Docker's restart delay.

  • A container that exits 1 immediately under always was restarted 13 times in 270 s. The gaps grow from under a second to roughly doubling (0.48, 0.58, 0.75, 1.31, 2.13, 3.46, 6.66, 13.07, 25.81, 51.44 s; the first few do not double cleanly) and settle at about 60 s (60.24, 60.27 s in replica 1). The Docker page cited here does not document the back-off, so the 60 s cap is only what this run measured. A few tenths of a second of start-up overhead in each gap is our guess; it was not isolated.
  • on-failure:3 made 4 starts (the original plus 3 retries), then stayed exited with exit code 1.
  • A container that ran for 12 s before failing was restarted about every 12.3 s, with no growth over 22 restarts. The docs say a policy takes effect only after a container has run successfully for at least 10 s, and the 12 s runs here did not accumulate delay.

Steps

The test script creates the containers like this (policy substituted for each of the four):

docker run -d --name g-run-unless-stopped --restart unless-stopped -v $HOME/s:/s:ro alpine:3.22 sh /s/graceful.sh
docker run -d --name k-run-unless-stopped --restart unless-stopped alpine:3.22 sleep 100000
docker stop k-stopped-unless-stopped   # for the "stopped by hand" case
sudo systemctl reboot

graceful.sh is three lines: a trap 'exit 0' TERM INT, then sleep 100000 & and wait. In Compose, the same setting is restart: unless-stopped on the service.

lab/tests/restart-policies/run.sh
Show the full file
#!/usr/bin/env bash
# restart-policies: which containers return after a daemon restart and after 3 VM reboots, and crash-loop back-off timing.
set -uo pipefail
source "$(dirname "$0")/../_lib.sh"
T=restart-policies; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"; rm -rf "$OUT"; mkdir -p "$RAW"
start_vm a-$T
POLICIES=(no always on-failure unless-stopped)
state() { gx "docker ps -a --format '{{.Names}}\t{{.State}}\t{{.Status}}' | sort"; }
gx 'docker pull -q alpine:3.22 >/dev/null; mkdir -p ~/s'
gx 'cat > ~/s/graceful.sh; chmod +x ~/s/graceful.sh' <<'EOS'
#!/bin/sh
# exits 0 on SIGTERM (a well-behaved service)
trap 'exit 0' TERM INT
sleep 100000 &
wait
EOS
gx 'cat > ~/s/crash.sh' <<'EOS'
#!/bin/sh
cut -d' ' -f1 /proc/uptime
exit 1
EOS
gx 'cat > ~/s/run12.sh' <<'EOS'
#!/bin/sh
cut -d' ' -f1 /proc/uptime
sleep 12
exit 1
EOS
# g-* = process that exits 0 on SIGTERM; k-* = `sleep` as PID 1, which ignores SIGTERM and is killed (exit 137) after the stop timeout
for p in "${POLICIES[@]}"; do
  gx "docker run -d --name g-run-$p --restart $p -v \$HOME/s:/s:ro alpine:3.22 sh /s/graceful.sh >/dev/null; docker run -d --name g-stopped-$p --restart $p -v \$HOME/s:/s:ro alpine:3.22 sh /s/graceful.sh >/dev/null; docker stop g-stopped-$p >/dev/null
docker run -d --name k-run-$p --restart $p alpine:3.22 sleep 100000 >/dev/null; docker run -d --name k-stopped-$p --restart $p alpine:3.22 sleep 100000 >/dev/null; docker stop k-stopped-$p >/dev/null"
done
gx 'docker run -d --name oneshot-ok-on-failure --restart on-failure alpine:3.22 sh -c "exit 0" >/dev/null; true'
sleep 8
state > "$RAW/0-before-anything.tsv"
# Phase 1: daemon restart only (no reboot)
gx 'sudo systemctl restart docker'; sleep 15
state > "$RAW/1-after-daemon-restart.tsv"
# Phase 2: three reboots. After each, sample every 3 s for 30 s from first ssh success.
# Before EVERY reboot the A/B layout is reset (run containers started, stopped containers stopped by hand), so each reboot starts from the same state.
for n in 1 2 3; do
  for p in "${POLICIES[@]}"; do gx "docker start g-run-$p k-run-$p >/dev/null 2>&1; docker stop g-stopped-$p k-stopped-$p >/dev/null 2>&1"; done
  sleep 3; state > "$RAW/2-before-reboot-$n.tsv"
  gx 'sudo systemctl reboot' </dev/null >/dev/null 2>&1
  sleep 15
  for _ in $(seq 1 80); do gx true 2>/dev/null && break; sleep 2; done
  { for s in 1 2 3 4 5 6 7 8 9 10; do
      echo "## uptime_s=$(gx 'cut -d" " -f1 /proc/uptime') docker_active_since=$(gx 'systemctl show docker -p ActiveEnterTimestampMonotonic --value')"
      state; sleep 3
    done; } > "$RAW/3-reboot-$n.txt" 2>&1
done
# Phase 3: crash loops. 3 identical containers per scenario so timings have replicates.
gx 'for i in 1 2 3; do
  docker run -d --name crash-always-$i --restart always -v $HOME/s:/s:ro alpine:3.22 sh /s/crash.sh >/dev/null
  docker run -d --name crash-onfail3-$i --restart on-failure:3 -v $HOME/s:/s:ro alpine:3.22 sh /s/crash.sh >/dev/null
  docker run -d --name run12-always-$i --restart always -v $HOME/s:/s:ro alpine:3.22 sh /s/run12.sh >/dev/null
done; true'
sleep 270
for c in crash-always-1 crash-always-2 crash-always-3 crash-onfail3-1 crash-onfail3-2 crash-onfail3-3 run12-always-1 run12-always-2 run12-always-3; do
  gx "docker logs $c 2>&1 | tr '\n' ' '; echo; docker inspect -f 'RestartCount={{.RestartCount}} State={{.State.Status}} ExitCode={{.State.ExitCode}}' $c" > "$RAW/4-crash-$c.txt" 2>&1
done
gx 'docker ps -a --format "{{.Names}}\t{{.Status}}" | sort' > "$RAW/4-crash-final-ps.tsv"
collect_env "$OUT"
python3 "$ROOT/lab/tests/$T/summarize.py" "$RAW" "$OUT"

What went wrong

The first run of this test used only sleep as the workload. It showed on-failure returning after every reboot, which contradicted the docs, so the script was changed to also use a process that exits 0 on SIGTERM; that run is the one reported. Its crash-loop timestamps had also come from busybox date +%s.%N, which printed no fractional part, so the script now reads /proc/uptime instead.

What this test did not cover

Sources and how this was made

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.