Startup Ordering That Actually Works: Healthchecks and depends_on in Compose
Compose depends_on only waits for containers to start, not to be ready. Gate startup on real readiness with healthchecks and condition: service_healthy — and know what that guarantee does not cover.
Published on • October 12, 2026
AI Assistant

docker compose up on a fresh machine, and your API dies eight times in a row with ECONNREFUSED before it finally stays up. On a laptop you barely notice. In production, with restart: always, the same race turns into a flapping service, misleading dashboards, and — worst of all — a bug report nobody can reproduce because the tenth restart always succeeds.
The root cause is one line of YAML you already wrote: depends_on. It orders startup, but it does not wait for readiness. Docker’s own docs put it plainly: on startup, Compose does not wait until a container is “ready”, only until it’s running.
What depends_on actually guarantees
The short syntax is the form most people use:
services:
api:
build: .
depends_on:
- db
- redis
db:
image: postgres:18
redis:
image: redis:7
This is real, useful ordering: Compose creates services in dependency order (db and redis before api) and removes them in the reverse order. But as the Compose services reference states, with the short syntax Compose does not wait for dependency services to be “healthy” before starting a dependent service — it only guarantees they have been started.
“Started” and “ready” are different states. A freshly created Postgres data directory initializes before it accepts connections. Redis may still be loading its dataset. Your API process, started a millisecond after the database container’s PID 1, tries to connect, gets refused, and exits. If a restart policy is set, the loop repeats until the database happens to win the race. Ordering that actually works needs a readiness signal.
The fix, part one: declare a healthcheck
The healthcheck block tells the platform how to test a container. It works the same way, and has the same default values, as the HEALTHCHECK Dockerfile instruction — and a Compose file can override whatever the image declares:
services:
db:
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 10s
retries: 5
start_period: 30s
The options, with their documented defaults (inherited from the Dockerfile instruction):
test— the command that decides health. As a list, the first item must beCMD,CMD-SHELL, orNONE; as a plain string it is equivalent toCMD-SHELL.NONE— ordisable: true— turns the check off, which is mainly useful for cancelling a healthcheck baked into an image.interval— how often to run the check (default30s).timeout— max duration of a single run (default30s). A check that overruns it is forcefully stopped withSIGKILLand counted as a failure.retries— consecutive failures tolerated before the container is markedunhealthy(default3).start_period— grace time for bootstrapping (default0s); failures during it do not count towardretries.start_interval— probe frequency during the start period (default5s; requires Docker Engine 25.0+).
The exit code is the verdict: 0 means healthy and ready, 1 means unhealthy, and 2 is reserved — don’t use it. The container’s health status starts at starting, flips to healthy on any successful check, and moves to unhealthy only after retries consecutive failures.
Note the $$ in the example: Compose interpolates $VARS in the file itself, so $$ defers the expansion to the shell inside the container, where ${POSTGRES_USER} actually exists.
The fix, part two: gate on service_healthy
The long syntax of depends_on turns ordering from “started” into “ready”:
services:
api:
build: .
depends_on:
db:
condition: service_healthy
restart: true
redis:
condition: service_started
redis:
image: redis:7
db:
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 10s
retries: 5
start_period: 30s
Compose now waits for the db healthcheck to pass before creating api, while redis only needs to be running — which is honest, because most cache clients already retry on their own.
The three condition values cover the real-world cases:
service_started— the short-syntax behavior: the dependency is merely running.service_healthy— the dependency reports healthy per itshealthcheck.service_completed_successfully— the dependency ran to completion with a zero exit. This is the right gate for one-shot services: migrations, schema imports, seed jobs.
Two extra fields are worth knowing. required: false downgrades a missing dependency from a hard failure to a warning. And restart: true — on the dependency entry, not to be confused with a service’s restart policy — makes Compose restart the dependent service when the dependency is explicitly restarted by a Compose operation such as docker compose restart db. It deliberately does not cover the container runtime’s own automated restarts after a crash.
Tuning for slow boots: retries and start_period
A database that runs migrations on first boot can take minutes, and a naive check will fail long enough to be declared unhealthy. start_period exists for exactly this: probe failures inside the start period do not count toward the retry budget, but the first success ends the grace period and normal counting begins. Give slow services a generous start_period, keep interval short (10s), and raise retries so a transient hiccup doesn’t flip the label.
If migrations belong to your app rather than the database image, prefer an explicit one-shot service over stretching start_period to five minutes:
services:
migrate:
image: registry.example.com/api:1.4.2
command: ["npm", "run", "migrate"]
api:
image: registry.example.com/api:1.4.2
depends_on:
migrate:
condition: service_completed_successfully
db:
condition: service_healthy
What health-gated ordering does not do
Being precise about the guarantee keeps it trustworthy:
- It never stops or restarts an unhealthy container. Health is a label Docker tracks alongside the normal running status. A container can be simultaneously up and unhealthy, and it keeps serving (or failing) until its process exits. Restart policies such as
alwaysandon-failurereact to exits, not to health. - The condition is evaluated while the project starts. Compose waits for healthchecks on dependencies marked with
service_healthybefore starting dependents. If a dependency later degrades tounhealthy, nothing happens to the services that already started. - A dependency that never goes healthy blocks everything downstream.
docker compose upsimply waits. That’s better than a crash loop, but it means a broken probe — missingcurlin a slim image, a wrong username inpg_isready— presents as a hang. Watch theupoutput while you add checks. - Readiness probes are not application logic.
pg_isreadymeans the server accepts connections — not that your schema is migrated, that migrations aren’t mid-run, or that Redis has loaded its dataset. Keep connection retries in your application code; health-gated ordering removes the common race, it doesn’t remove the need for resilience.
Seeing health from the outside
docker compose ps lists project containers with their current status. For scripting, the JSON form exposes a Health field per container:
docker compose ps
docker compose ps --format json | jq -r 'select(.Service == "db") | .Health'
A starting that never becomes healthy is your signal to inspect the probe itself — docker compose logs db will usually show whether the check command is even being executed.
Closing
Startup ordering is a two-line habit: a healthcheck that means something, and depends_on in long syntax with condition: service_healthy. Everything else — retries on the client side, migrations as first-class services — builds on that foundation. The authoritative references are Docker’s guide to controlling startup and shutdown order in Compose, the Compose services reference for depends_on and healthcheck, and the Dockerfile reference for the health status machine and its defaults.