Ch. 13 · Docker

Docker Compose Dependencies and Readiness

Plain depends_on only orders container starts. Use healthchecks, service_healthy and service_completed_successfully to wait for a ready database.

~5 min readintermediateupdated Oct 7, 2026

The bug report goes like this: “docker compose up works on my machine, but in CI the app crashes on start with connection refused, and if I run up a second time it’s fine.” The Compose file says depends_on: [db], so the author assumed the database would be ready first. It wasn’t. depends_on was doing exactly what it promises, which is less than people think.

The stacks below use the official postgres:16-alpine image for the database and, to keep things small, also as the “app” (its psql client stands in for an application’s first query). Run with Docker Compose v2.

What plain depends_on does

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
  app:
    image: postgres:16-alpine
    environment:
      PGPASSWORD: example
    command: ["psql", "-h", "db", "-U", "postgres", "-c", "SELECT 1;"]
    depends_on:
      - db
yaml
 Container shop-db-1 Started
 Container shop-app-1 Started
app-1  | psql: error: connection to server at "db" (172.19.0.2), port 5432 failed: Connection refused
app-1  | 	Is the server running on that host and accepting TCP/IP connections?
app-1 exited with code 2
Text

Compose created and started db before app, as requested. It didn’t wait for anything inside db. A container is “started” the instant its main process is launched; Postgres then spends a few seconds (much longer on first run, when it initialises the data directory) before it listens on 5432. app got there first.

Notice the error came from a successfully resolved name: db mapped to 172.19.0.2. DNS worked, the port just wasn’t open yet. That’s how you tell an ordering problem apart from a networking problem.

The short form also controls shutdown order (dependents stop first) and makes docker compose up app start db too. Those are useful. Readiness isn’t part of it.

Waiting for a health check

The long form of depends_on takes a condition. service_healthy waits until the dependency’s health check passes, so the dependency needs one:

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1"]
      interval: 2s
      timeout: 3s
      retries: 10
      start_period: 30s
  app:
    image: postgres:16-alpine
    environment:
      PGPASSWORD: example
    command: ["psql", "-h", "db", "-U", "postgres", "-c", "SELECT count(*) FROM orders;"]
    depends_on:
      db:
        condition: service_healthy
yaml

Compose now prints a Waiting phase and only moves on once the check passes:

 Container shop-db-1 Started
 Container shop-db-1 Waiting
 Container shop-db-1 Healthy
 Container shop-app-1 Starting
Text

The health check is now load-bearing, so it has to mean “ready for the app”, not “process exists”. Two details in the one above earn their place:

  • -h 127.0.0.1. On first start, the official Postgres image runs a temporary server to execute init scripts, listening only on its Unix socket, then shuts it down and starts the real one. pg_isready without -h checks the socket and can report ready during that temporary phase; the app then connects just as the server restarts. Checking over TCP avoids the gap.
  • start_period and retries. First-time initialisation on a slow CI runner can take 20 seconds or more. With the defaults (30-second interval, no start period), Compose would wait a long time for the first check; with an aggressive interval and too few retries, it could give up with dependency failed to start: container shop-db-1 is unhealthy. Give slow starters a generous start period and a short interval.

If the readiness you need is something the database can’t report, like a particular schema version, the health check isn’t the right tool. That’s the next condition’s job.

One-off jobs: migrations before the app

service_completed_successfully waits for a dependency to run to completion and exit with code 0. It’s the natural home for migrations, seeding and other set-up tasks:

  migrate:
    image: postgres:16-alpine
    environment:
      PGPASSWORD: example
    command: ["psql", "-h", "db", "-U", "postgres", "-c", "CREATE TABLE IF NOT EXISTS orders(id int);"]
    depends_on:
      db:
        condition: service_healthy
  app:
    # ...as before
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
yaml
 Container shop-db-1 Healthy
 Container shop-migrate-1 Started
 Container shop-migrate-1 Exited
 Container shop-app-1 Started
$ docker compose logs app migrate --no-log-prefix
CREATE TABLE
 count
-------
     0
$ docker compose ps -a --format '{{.Service}} {{.Status}}'
app Exited (0) 2 seconds ago
db Up 8 seconds (healthy)
migrate Exited (0) 2 seconds ago
Text

The chain is db healthy, then migrate runs and exits 0, then the app starts against a schema that exists. If the migration exits non-zero, app never starts and Compose reports the failed dependency, which is what you want: no app running against a half-migrated database. Make the migration idempotent (IF NOT EXISTS, or a tool like Flyway or Alembic that tracks applied versions), because it runs on every up.

One trap when you try this in a script: docker compose up --abort-on-container-exit stops the whole stack as soon as any container exits, including a migration that finished successfully. Use up -d and docker compose wait, or check logs, instead.

Ordering is not resilience

All of this happens at docker compose up. Once the stack is running, Compose doesn’t hold dependents back when the database restarts, gets recreated with a new image, or briefly drops connections during a failover. (Adding restart: true to a long-form dependency makes Compose restart the dependent when the dependency is restarted by a Compose operation, which helps with explicit restarts but not with crashes or network blips.) In Kubernetes there’s no depends_on at all; pods start in any order.

So the application must handle an unavailable dependency anyway: retry the initial connection with backoff for a bounded time, reconnect on failure, and fail its own readiness check meanwhile. With that in place, service_healthy is a nicety that gives clean start-up logs and faster local runs. Without it, depends_on is papering over a bug that will show up in production. Interviewers often ask this precisely to see whether you’ll stop at the Compose answer.

Interview tip

Old guides use wait-for-it.sh or dockerize wrappers in the entrypoint. They still work, but condition: service_healthy covers the same need declaratively, and in-app retries cover the cases neither can.

Interview practice

A Compose stack has api depending on db with condition: service_healthy. The db health check is test: ["CMD", "pg_isready"] with default timing. Developers report that on fresh clones docker compose up sometimes takes over a minute before api starts, and occasionally api still fails its first query with “the database system is starting up”. What’s going on, and what would you change?

Answer

With default timing, the first health check runs 30 seconds after start, and if it fails the next is another 30 seconds later, so a database that’s ready at second 5 can wait 30 or 60 seconds to be marked healthy. That’s the slow start-up.

The occasional failure comes from pg_isready checking the Unix socket. On a fresh volume the Postgres image runs a temporary server for initialisation, the socket check can pass during that window, api starts, and its first query hits the server while it’s restarting.

Fix the check: pg_isready -U postgres -h 127.0.0.1 (TCP, so the temporary socket-only server doesn’t count), with interval: 2s, timeout: 3s, retries: 10 and a start_period around 30 seconds, or use start_interval to probe quickly only during start-up. Then make api retry its database connection with backoff so it survives any remaining gap and later restarts.

Keep going

More in Docker

read ✓Docker · mid

Docker Health Checks and Useful Probes

How a HEALTHCHECK moves from starting to unhealthy, why plain Docker never restarts an unhealthy container, and what a good probe checks.

~5 min readread →
read ✓Docker · mid

Docker Networks and Container DNS

Why containers can't resolve each other on the default bridge, how the 127.0.0.11 resolver works, and why nginx keeps a stale upstream IP.

~5 min readread →
esc