My n8n upgrade runbook has a line in it that I wrote by hand, sometime in 2026:
After the main container reports healthy, run
docker restart n8n-docker-caddy-n8n-worker-1.
I have run that line at every version bump for months. It works. I never questioned it.
Last week I finally read the Compose specification closely enough to notice that I had hand-implemented a field that has existed since Compose 2.17.0, released in 2023. The field is depends_on.<service>.restart. My runbook is a human being standing in for one boolean.
So I went and measured how widespread the gap actually is — not in a demo repo, in the fleet that runs my clients' work.
The measurement
Eight servers, read-only, on 2026-09-17. I pulled two things from every running container: its healthcheck configuration, and the com.docker.compose.depends_on label that Compose v2 writes at create time. That label is the ground truth — it records the resolved dependency as service:condition:restart, so I didn't have to trust the YAML on disk matching what is actually running.
for c in $(docker ps --format '{{.Names}}'); do
docker inspect "$c" --format \
'{{index .Config.Labels "com.docker.compose.project"}}|{{.Name}}|{{index .Config.Labels "com.docker.compose.depends_on"}}'
done
Results across 38 running containers, in 14 Compose projects, on 8 servers:
| count | |
|---|---|
| Containers with a healthcheck | 33 / 38 |
Containers with any depends_on
|
7 / 38 |
Of those 7, using condition: service_healthy
|
7 / 7 |
Of those 7, using restart: true
|
0 / 7 |
Every dependency I had bothered to declare was declared well — all seven wait for health, none of them settle for service_started. And every single one of them ends in :false.
That trailing false is the restart flag. It means: when Compose updates the dependency, do not restart me.
The 31 containers with no depends_on at all are a separate confession, and I'll come back to them.
What depends_on actually promises
The long syntax takes three sub-options, and it is worth being precise about the scope of each:
-
condition: service_started— the dependency's container has been created and started. Says nothing about the process inside it. -
condition: service_healthy— the dependency's healthcheck has passed. This is the one everybody reaches for. -
condition: service_completed_successfully— the dependency ran to exit 0. For migration jobs and seeders. -
restart: true— "Compose restarts this service after it updates the dependency service." -
required: false— downgrade a missing dependency from an error to a warning.
Read the restart line again. It is scoped to Compose-controlled updates — the docker compose up -d that replaces a container with a new image. It is explicitly not about runtime crashes; your restart policy covers those.
So condition and restart answer two genuinely different questions:
- condition → in what order do these containers come up the first time?
- restart → what happens to me when the thing underneath me gets replaced?
A stack with condition: service_healthy everywhere and restart nowhere is fully ordered on a cold boot and fully unordered on every deploy after that. Which, on a server that boots once a year and deploys twice a month, means the feature is protecting the rare case and sitting out the common one.
Where condition alone was not enough
Two dated incidents from this fleet, both of which I originally filed as something other than what they were.
2026-07-31 — the healthy database that wasn't ready. Upgrading n8n from 2.30.6 to 2.32.6 on the queue-mode server. The worker's declared dependency reads, to this day, postgres:service_healthy,redis:service_healthy. Postgres was genuinely healthy. The worker still died — stack trace in MigrationExecutor.executePendingMigrations, then unhealthy and stuck there.
The reason is that service_healthy asked Postgres whether it was accepting connections, and Postgres honestly said yes. Nobody asked the question that mattered: has the schema been migrated to the new version yet? The main container and the worker both came up against the same database and both started running migrations. Main won. The worker crashed on a half-applied schema.
There is no healthcheck you can write on Postgres that answers "is the application's schema current", because the answer doesn't live in Postgres — it lives in whichever sibling container is doing the migrating. The fix I actually deployed was that runbook line: wait for main, restart the worker. Which is depends_on: { n8n: { condition: service_healthy, restart: true } } performed manually.
2026-09-04 — the update that kept everything running and broke one thing anyway. Docker engine 29.7.2 → 29.8.0 across the fleet. live-restore did its job: every container survived, 8→8, 5→5, 3→3. Except supabase-docker-proxy, which went unhealthy with:
ERROR Docker API proxy error error="dial unix /var/run/docker.sock: connect: connection refused"
It mounts /var/run/docker.sock. Replacing the daemon replaces the socket; the process inside kept holding the old one and never reconnected. live-restore keeps the container running — it cannot heal a client that lost its connection to the API.
The tell was ugly: docker ps showed Up 3 days (unhealthy). The uptime was true. It really had not fallen over. FailingStreak was 75, and that was the number that said when it actually broke. A docker restart fixed it in fifteen seconds.
Note what both incidents have in common. Neither container crashed. Both needed to be restarted because something underneath them changed, and in both cases the thing that noticed was a person.
The 31 with no depends_on
The honest part of the audit. Four of them are application containers in production: the Rails and Sidekiq containers of two self-hosted Chatwoot instances. They have no declared dependency on their own Postgres or Redis. On a cold boot they race, lose, crash, and get resurrected by restart: always.
That works. It has worked for a year. It is also the reason that stack's real recovery time is invisible — the recovery is a crash loop that happens to terminate, and nothing records how long it took.
While I was in there I measured the other number that deploys care about. Those Rails containers run start_period: 60s, interval: 60s, retries: 3. Add it up: a Rails container that comes back up broken can present as starting, then healthy-adjacent, for up to four minutes before Docker is willing to call it unhealthy. If your deploy script polls health to decide whether to roll back, that is the size of your blind spot.
What I changed
For the dependencies that already exist, adding one line each:
services:
n8n-worker:
depends_on:
postgres:
condition: service_healthy
n8n:
condition: service_healthy
restart: true # ← replace main, and I get restarted after it
Two changes in that block, not one. The restart: true is the field I had been simulating by hand. The new n8n entry is the fix for the 2026-07-31 incident: the worker's real dependency was never Postgres, it was whoever migrates Postgres.
Run this on your own fleet before you assume you're clean — it reads the resolved labels, not the YAML you think is deployed:
for c in $(docker ps --format '{{.Names}}'); do
d=$(docker inspect "$c" --format '{{index .Config.Labels "com.docker.compose.depends_on"}}')
[ -n "$d" ] && echo "$c -> $d"
done
Anything ending in :false is a container that will keep running against a dependency you just replaced.
Two caveats I'd rather state than have corrected in the comments. First, restart: true is not free — it widens each deploy, because more containers cycle. On a stack where the dependency changes often, that is the trade you are making. Second, it is still not zero-downtime; it is ordered downtime. Real zero-downtime means two versions serving at once behind a proxy, and Compose is not the tool for that. What this fixes is the cheaper, more common failure: the deploy that finishes green while something behind it is quietly talking to a socket that no longer exists.
Most of the infrastructure work at Achiya Automation looks like this — not clever, just the boring field nobody read the docs for.
The question
I'm curious whether the :false ratio is a me problem or an everyone problem. Run the loop above and post your two numbers: how many of your running containers declare depends_on at all, and how many of those end in :true?
And if you're above zero on the second number — what made you go looking? I only found it because I noticed I'd written the feature into a runbook by hand.
Top comments (0)