EduFlow

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.

  1. 1What depends_on really waits for
  2. 2Healthchecks
  3. 3Waiting on conditions
  4. 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.

YAML
services:
  api:
    depends_on:
      - db

Compose 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.

compose.yaml
  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: 1s

pg_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 marked unhealthy.
  • start_period: a grace period after start during which failures don’t count toward retries.
  • start_interval: how often to check during the start period, so a fast start is detected quickly instead of waiting a full interval.

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:

Terminal
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:

compose.yaml
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_successfully

Three 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.

Timeline: db starts and becomes healthy after its checks pass; then migrate runs and exits with code 0; then api starts. db migrate api starting healthy running exit 0 serving time
Startup order with conditions

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:

YAML
    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: 3

In 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_on only waits for the dependency’s container to start, not for it to be ready to serve.
  • A healthcheck tells Docker how to test a container; condition: service_healthy makes a dependent service wait for that test to pass.
  • condition: service_completed_successfully runs one-off jobs such as migrations before the app starts.
  • In exec form each argument is a separate list item; use CMD-SHELL when 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_on at all.

Further reading

Check your understanding

4 questions

Question 1 of 4

api has depends_on: [db] in short form. On a cold start the API logs ECONNREFUSED once, then works. Why?

Question 2 of 4

Which healthcheck correctly tests Postgres readiness in the db service?

Question 3 of 4

A migrate service applies schema changes and exits. How should api depend on it?

Question 4 of 4

Your stack uses healthchecks and service_healthy. Why should notes-api still retry failed database connections?

Practice

Spot the bug

Healthy on paper

With this compose.yaml, db shows (unhealthy) forever even though psql works fine, and the API still crashes on cold starts with ECONNREFUSED. Select the two lines to fix.

Select the line(s) you think are wrong, then check.