2026-07-28
Use Docker Compose lifecycle hooks only for omittable work
Classify post_start and pre_stop work by ordering and omission, then test managed stop, SIGKILL, and self-exit behavior.
Put work in post_start only when the main process may begin and become useful before the hook runs or finishes. Put work in pre_stop only when the service remains correct if the hook never runs. In both cases, the work must be bounded, repeatable, safe to omit, and non-authoritative. If skipping it can corrupt state, violate an invariant, make startup falsely ready, or remove the only recoverable copy, it does not belong in a hook. The main process and the surrounding platform own correctness; hooks can improve an already safe lifecycle but cannot create its safety property.
These are deliberately weak guarantees. Docker says post_start has no ordering guarantee against the container entrypoint, and pre_stop does not run when a container stops on its own or is terminated suddenly. Use lifecycle hooks and the services reference define those limits.
Evidence boundary
Compose semantics were source-reviewed on 2026-07-28. Service hooks arrived in Docker Compose v2.30.0; the current services reference requires Compose 2.30.0 or later for post_start and pre_stop, documents command, user, privileged, working_dir, and environment for post_start, and makes pre_stop configuration equivalent. The Compose specification independently records the same hook model and timing limit.
For this publication, I ran the retained proof with Docker Compose 5.3.1 and alpine:3.23. It passed managed stop, SIGKILL, and application self-exit cases. I did not execute older versions, newer versions, other images, orchestrators, production workloads, hook failure behavior as a general contract, or real application invariants. The fixture proves Compose hook invocation and omission markers only. It does not prove application state safety, queue semantics, data durability, or traffic draining.
The current reference also documents pre_start for Compose 5.3.0 or later: init steps complete before the service container starts. An operator who truly needs a pre-entrypoint phase should evaluate pre_start on a compatible installation. That makes it a possible stronger owner for ordered initialization, not a reason to treat post_start as a startup barrier. This article did not execute a pre_start case. The pre_start reference is the version-specific source.
Classify by omission, not convenience
| Question | If yes | Consequence |
|---|---|---|
| Must this finish before the application starts serving or reports ready? | The task needs a startup barrier. | Do not use post_start. |
| Must this happen on every termination path? | Omission changes correctness or durability. | Do not use pre_stop. |
| Can the task be skipped, repeated, or interrupted without violating an invariant? | The core lifecycle remains safe. | It may be a hook candidate. |
| Does it need broader user or privilege settings? | The hook expands authority. | Require a separate least-privilege review. |
"May be a hook candidate" is not approval. The task still needs a bound, useful observability, and a test in the real service.
A deliberately weak-hook fixture
The retained fixture is below in full. A non-root service reads its script from a read-only mount and writes markers to a separate state mount. The markers expose timing, without assigning business correctness to either hook. Save the files at these exact paths relative to one directory: compose.yaml, fixture/main.sh, and run-proof.sh.
compose.yaml:
services:
app:
image: alpine:3.23
user: "1000:1000"
init: true
working_dir: /work
command: ["/bin/sh", "/work/main.sh"]
volumes:
- ./fixture:/work:ro
- ./state:/state
healthcheck:
test: ["CMD-SHELL", "test -f /state/app-ready"]
interval: 200ms
timeout: 1s
retries: 20
post_start:
- command: ["/bin/sh", "-c", "printf 'post-start\\n' >> /state/events; sleep 3; printf 'post-done\\n' >> /state/events"]
pre_stop:
- command: ["/bin/sh", "-c", "printf 'pre-stop\\n' >> /state/events; sleep 1; printf 'pre-stop-done\\n' >> /state/events"]
stop_grace_period: 5sfixture/main.sh:
#!/bin/sh
set -eu
on_term() {
printf 'app-term\n' >> /state/events
rm -f /state/app-ready
exit 0
}
trap on_term TERM INT
printf 'app-start\n' >> /state/events
touch /state/app-ready
while [ ! -f /state/exit-now ]; do
sleep 0.2 &
wait "$!"
done
printf 'app-self-exit\n' >> /state/events
rm -f /state/app-readyrun-proof.sh:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
export COMPOSE_PROJECT_NAME=portfoliohookproof
cleanup() {
docker compose down --remove-orphans --volumes >/dev/null 2>&1 || true
}
trap cleanup EXIT
reset_case() {
cleanup
rm -rf state
mkdir state
chmod 0777 state
}
show_events() {
while IFS= read -r line; do
printf ' %s\n' "$line"
done < state/events
}
assert_case() {
local mode="$1"
python3 - "$mode" state/events <<'PY'
import pathlib, sys
mode = sys.argv[1]
events = pathlib.Path(sys.argv[2]).read_text().splitlines()
if mode == "managed":
required = ["app-start", "post-start", "post-done", "pre-stop", "pre-stop-done", "app-term"]
for item in required:
assert item in events, (item, events)
assert events.index("post-start") < events.index("post-done")
assert events.index("pre-stop") < events.index("pre-stop-done") < events.index("app-term")
elif mode == "kill":
assert "post-done" in events, events
assert "pre-stop" not in events, events
assert "app-term" not in events, events
elif mode == "self":
assert "post-done" in events, events
assert "app-self-exit" in events, events
assert "pre-stop" not in events, events
assert "app-term" not in events, events
else:
raise AssertionError(mode)
PY
}
printf 'compose_version=%s\n' "$(docker compose version --short)"
reset_case
docker compose up -d --wait
printf '%s\n' 'managed_after_up:'
show_events
docker compose stop -t 5
printf '%s\n' 'managed_after_stop:'
show_events
assert_case managed
reset_case
docker compose up -d --wait
docker compose kill -s KILL app
printf '%s\n' 'kill_after_kill:'
show_events
assert_case kill
reset_case
docker compose up -d --wait
touch state/exit-now
container_id="$(docker compose ps -q app)"
for _ in $(seq 1 50); do
status="$(docker inspect --format '{{.State.Status}}' "$container_id")"
[ "$status" = exited ] && break
sleep 0.1
done
[ "${status:-}" = exited ]
printf '%s\n' 'self_exit_after_exit:'
show_events
assert_case self
printf '%s\n' 'RESULT=PASS managed_stop_hook=yes kill_hook=no self_exit_hook=no'The runner makes state writable by the service UID on every reset, runs the managed-stop, SIGKILL, and self-exit cases, performs the exact assertions, and cleans up its Compose project on exit. To make the runner executable and invoke it from the directory containing those paths:
chmod +x run-proof.sh
./run-proof.shThe trap is not protection against SIGKILL. It cannot run on that path.
Assertions from the retained run
| Case | Locally asserted |
|---|---|
| Managed stop | All six markers existed. post-start preceded post-done. pre-stop preceded pre-stop-done, which preceded app-term. |
| SIGKILL | post-done existed. Neither pre-stop nor app-term existed. |
| Self-exit | post-done and app-self-exit existed. Neither pre-stop nor app-term existed. |
The runner did not assert an order between app-start and post-start, and Docker supplies no such order guarantee. The lifecycle guide is explicit about the entrypoint race. The local result also does not show that a hook always completes, nor does it establish SIGKILL or self-exit safety for a real application. It shows why a design cannot credit pre_stop or the application TERM trap for those paths.
To prove a real design still works when pre_stop is skipped, run sudden termination and self-exit cases, then verify the application's actual invariant without crediting the hook. The retained Alpine fixture proves only invocation and omission.
RESULT=PASS managed_stop_hook=yes kill_hook=no self_exit_hook=noThe same local Compose 5.3.1 exercise included disposable failing hooks. A post_start exit 42 made up return 1 while the container remained running. A pre_stop exit 42 made stop return 1 while the container remained running, so cleanup required SIGKILL. Those are local observations for Compose 5.3.1 only, not cross-version guarantees; the documented contract remains the source of truth.
Allow and reject matrix
| Hook | Conditional fit | Reject when |
|---|---|---|
post_start |
Best-effort service registration where availability and correctness do not depend on completion, and the registry independently reconciles or expires stale entries. Advisory metadata emission. Warming a disposable optimization that the application can miss and rebuild. | The work is a schema or data migration, readiness gate, required ownership or permission change, required configuration or credential generation, or acquisition of a singleton lease needed for safe behavior. |
pre_stop |
Best-effort deregistration where stale entries expire or reconcile independently. Advisory drain notification where the traffic system also detects failure. Flushing disposable diagnostics or telemetry where losing the final batch is acceptable. | The work is the only durable backup, transaction commit, queue acknowledgement, checkpoint, state flush, readiness withdrawal, traffic removal mechanism, or cleanup whose omission blocks the next start. Reject anything described as "must run." |
Registration and drain actions are not safe merely because lifecycle documentation uses similar examples. The question is whether their omission leaves the system correct.
Put correctness in stronger owners
| Requirement | Stronger owner |
|---|---|
| Ordered initialization before the service container starts | A reviewed pre_start design on Compose 5.3.0 or later, or an explicit deployment job. Keep migrations separately controlled. |
| Readiness | Application readiness plus a healthcheck that probes the real dependency or state. |
| Graceful managed shutdown | The main process signal handler with a bounded stop grace period. |
| Sudden termination safety | Transactions, leases, idempotency, reconciliation, replicated or durable state, and restart recovery outside the dying process. |
| Durable backup | A scheduled, monitored, restorable backup system independent of container stop. |
Acceptance procedure
- Name the invariant the service must preserve.
- Remove the hook and prove normal startup or stop still preserves it.
- Run managed stop and confirm the hook adds only its optional benefit.
- Run sudden termination and self-exit separately.
- Verify durable state, traffic behavior, locks or leases, restart behavior, and operator signals with application-specific probes.
- Reject the design if any required property is credited only to a
post_startorpre_stopmarker.
If omission is unsafe, move the work to a stronger lifecycle owner.