Section 3 · Local Environments with Docker Compose
Startup Order with Healthchecks
18 min4 min readSpot the bug
Lesson at a glance
Give Postgres and notes-api real healthchecks, make the API wait for a healthy database and a finished migration with depends_on conditions, and keep retry logic in the app anyway.
- 1What depends_on really waits for
- 2Healthchecks
- 3Waiting on conditions
- 4Retries still belong in the app
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Every cold start of the notes-api stack had the same flaky first minute: the API started, tried Postgres, got ECONNREFUSED, and crashed. The depends_on: [db] in the file looked like it should have prevented it. It didn’t, because “started” and “ready” are different things, and Compose only knew about the first.
What depends_on really waits for
The short form says: start db before api.
services:
api:
depends_on:
- dbCompose does exactly that: it starts the db container and then the api container, about a second apart. But Postgres needs several seconds after its process starts to initialise, more on the first run when it creates the database and runs your init scripts. The API connects during that window and is refused.
To wait for ready, Compose needs a way to test readiness. That’s a healthcheck.
Healthchecks
A healthcheck is a command Docker runs inside the container on a schedule. Exit code 0 means healthy; anything else counts as a failure.
db:
image: postgres:17
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: notes
POSTGRES_DB: notes
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
start_interval: 1spg_isready ships with the Postgres image and exits 0 once the server accepts connections. The timing fields:
interval: how often to run the check once the container is running normally.timeout: how long one check may take before it counts as failed.retries: consecutive failures before the container is markedunhealthy.start_period: a grace period after start during which failures don’t count towardretries.start_interval: how often to check during the start period, so a fast start is detected quickly instead of waiting a fullinterval.
docker compose ps now shows (healthy) or (unhealthy) next to the status. When a check fails and you want to know why, the last few results, including their output, are stored on the container:
docker inspect notes-db-1 --format '{{json .State.Health}}'One thing healthchecks don’t do in plain Docker or Compose: they don’t restart an unhealthy container. The status is information, used by depends_on conditions and by you. Kubernetes is different, as you’ll see with liveness probes in section 5, where a failing check does trigger a restart. Knowing which tool acts on a failed check, and which only reports it, matters at 3 a.m.
Waiting on conditions
The long form of depends_on lets you say what to wait for:
services:
migrate:
image: notes-api:dev
command: ["node", "dist/migrate.js"]
environment:
DATABASE_URL: postgres://notes:notes@db:5432/notes
depends_on:
db:
condition: service_healthy
api:
build: .
image: notes-api:dev
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://notes:notes@db:5432/notes
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullyThree conditions exist: service_started (the short form’s behaviour), service_healthy, and service_completed_successfully, which waits for a one-off container to exit with code 0. Here the migration runs once Postgres is healthy, and the API starts only after the migration succeeded. If the migration fails, api never starts, and you see why in docker compose logs migrate. restart: true means that if Compose restarts db (for example after you change its config), it restarts api too.
The API deserves a healthcheck too. The runtime image may be distroless with no curl, but it always has Node, and Node 24 has fetch built in. On node:24-slim, node is on the PATH; on distroless, call it by its full path, /nodejs/bin/node:
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s
timeout: 3s
retries: 3In CI, docker compose up -d --wait returns only when every service with a healthcheck is healthy, and fails if one turns unhealthy. That’s the right way to start a stack before integration tests, instead of a sleep 15.
Retries still belong in the app
All of this covers the first start. Nothing stops Postgres from restarting at 3 p.m. on a Tuesday, and when you move to Kubernetes in section 4, there’s no depends_on at all: Pods start in whatever order they’re scheduled. So notes-api connects with a retry and backoff, and reports “not ready” on /readyz until the database answers. Healthchecks and conditions make the happy path fast; retries make the unhappy path survivable.
The stack now comes up in the right order every time. The last Compose lesson tidies up configuration and gives you a fast edit-and-reload loop.
Key takeaways
- Short-form
depends_ononly waits for the dependency’s container to start, not for it to be ready to serve. - A
healthchecktells Docker how to test a container;condition: service_healthymakes a dependent service wait for that test to pass. condition: service_completed_successfullyruns one-off jobs such as migrations before the app starts.- In exec form each argument is a separate list item; use
CMD-SHELLwhen the test is a single shell command string. - Startup ordering covers only the first start, so the app must still retry database connections; Kubernetes has no
depends_onat all.