Docker Compose – depends_on, Healthchecks and Startup Order

July 27, 20264 min readUpdated 8/21/2026

You add depends_on: [mysql], run docker compose up, and the API dies with a connection refused. Compose did exactly what you asked. What you asked for was not what you wanted.

started is not ready

depends_on waits for the container, not for the service inside it. A container is "started" the instant its main process launches — and MySQL takes several more seconds after that to initialise its data directory, run its own start-up, and begin accepting connections on the socket.

So the sequence is: MySQL's container starts, Compose considers the dependency satisfied, the API starts, the API connects, MySQL is not listening yet, the API exits.

The symptom is a stack that works when you run up twice — because the second time MySQL was already warm — and fails on a clean machine or in CI. Which is the worst kind of bug to have, since it only appears where you cannot easily look.

A healthcheck

The fix is to define what "ready" means, and then wait for that:

    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s

Docker runs test inside the container on a schedule. Exit 0 is healthy, anything else is not.

The four timings, each doing something specific:

interval — how often to probe once it is up and running.

timeout — how long one probe may take before it counts as a failure. Worth setting deliberately: a probe that hangs rather than failing is what makes a stack sit there for minutes.

retries — consecutive failures before the container is marked unhealthy.

start_period — the grace window at the beginning during which failures do not count toward retries. This is the one people leave out and then wonder why a slow-starting service is marked unhealthy and killed before it ever had a chance. A JVM application with Liquibase migrations to run needs a generous one.

condition: service_healthy

The healthcheck alone changes nothing about ordering. It has to be depended on:

    depends_on:
      mysql:
        condition: service_healthy

Note the shape. The list form — depends_on: [mysql] — cannot express a condition; you need the map form to say anything more than "start it first".

Three conditions exist:

service_started            the default. Container exists. Says nothing about readiness.
service_healthy            the healthcheck is passing.
service_completed_successfully   the container ran and exited 0for a
                                 migration or seed job that must finish first.

And now the output says what you want it to:

$ docker compose up -d
 Container pizza-mysql-1  Starting
 Container pizza-mysql-1  Started
 Container pizza-mysql-1  Waiting
 Container pizza-mysql-1  Healthy
 Container pizza-api-1    Starting
 Container pizza-api-1    Started

Waiting, then Healthy, then the API. That is the race gone.

Not everything should wait

Worth resisting the urge to make every dependency service_healthy. The web tier in this stack deliberately does not:

    depends_on:
      - api

nginx serves static files perfectly well before the API is up, and it resolves api lazily, per request. Waiting would delay the login page for no benefit — and would mean a slow API start-up makes the whole site unavailable rather than just the data on it.

Ask what actually breaks if the dependency is not ready. If the answer is "the first request fails and then it works", service_started is right.

Check with something the image has

The rule from lesson 13, because it is the mistake that costs the most time here: a healthcheck that cannot run its own command reports unhealthy forever, which looks identical to a service that failed to start.

The demo app's Artemis broker has no curl in its image, so a curl-based probe "fails" every time while the broker serves happily. It uses the tool that is present:

      test: ["CMD", "/var/lib/artemis-instance/bin/artemis", "check", "node", "--up",
             "--user", "admin", "--password", "admin"]

Which also probes the messaging port rather than the web console — the port clients actually use.

Handy forms for common images:

test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
test: ["CMD-SHELL", "pg_isready -U stayhub -d stayhub"]
test: ["CMD-SHELL", "curl -fsS http://localhost:9200/_cluster/health || exit 1"]
test: ["CMD-SHELL", "wget -qO- http://localhost:8085/actuator/health || exit 1"]

CMD runs the command directly; CMD-SHELL wraps it in /bin/sh -c, which you need for pipes, || and variables.

⚠️ curl -f is doing real work in those: without it, curl exits 0 on an HTTP 500, and your healthcheck cheerfully reports a broken service as healthy.

up --wait

docker compose up -d --wait

Blocks until every service with a healthcheck is healthy, then returns — and returns non-zero if one never gets there. This is what you want in CI, where the next step is a test suite that assumes the stack is up.

Retry anyway

Healthchecks fix start-up ordering. They do not fix a database that restarts at three in the morning while your API is running, and depends_on has nothing to say about that — it applies to up, not to the rest of the service's life.

So the application still needs a connection pool that reconnects, and start-up retries with backoff. What healthchecks buy you is that the common case stops being a coin flip; they are not a substitute for an application that survives its dependencies going away.

Next: not starting everything every time.