Tech Digest

Guide

Why doesn't depends_on wait for my database in Docker Compose?

Plain `depends_on` only orders container starts. `condition: service_healthy` makes Compose wait, but only as well as the healthcheck it waits on, and two common checks get that wrong. Nothing in `depends_on` applies when the host reboots.

Last reviewed

Why doesn't depends_on wait for my database to be ready?

Plain depends_on only makes Compose start the database container first; it does not wait for Postgres or MariaDB to accept connections. Use the long syntax with condition: service_healthy and give the database a healthcheck. For Postgres, the check must use TCP (pg_isready -h 127.0.0.1), because the image's first-boot setup server answers on the Unix socket only. Set start_interval: 1s, or Compose waits the default 30 seconds before the first check.

Because plain depends_on waits for the database's container to start, not for the database inside it to accept connections. Compose's own documentation says what it guarantees: it "always starts and stops containers in dependency order". Order is all you get. To make the app wait, switch to the long syntax with condition: service_healthy and give the database a healthcheck. For Postgres that check has to go over TCP, pg_isready -h 127.0.0.1, and it needs start_interval set, or the fix either lets the app in too early or makes every start 30 seconds slower.

Two answers circulate, and the second one is only half right#

The first answer is "add depends_on: [db]". It is in nearly every compose file, and it often appears to work, which is why it survives. It is the service_started condition, which the compose reference describes as the default and as "an equivalent of the short syntax". The database container is running, the database is not.

The second answer is "use condition: service_healthy with pg_isready -U postgres". That is the right mechanism with a check that can pass before Postgres accepts the app's connections. It is the same shape as the healthcheck in Docker's own startup-order example, which also leaves out -h. On a first boot with an init script, our app got past it and then had its connections refused for 14.5 seconds. The reason is in the Postgres image's entrypoint, covered below.

What each setup did, measured#

We ran each case on Docker Engine 28.1.1 with Compose 2.35.1, using the official postgres:18-alpine image (PostgreSQL 18.6). The "app" was a second container that tried psql -h db -c 'select 1' over TCP every 0.1 seconds from the moment it started, and counted failures.

SetupApp started after the databaseWhat the app saw
depends_on: [db], first boot (empty volume)At onceConnection refused for 1.08 to 1.35 s, 10 to 12 failures, three runs
depends_on: [db], existing dataAt onceConnected first time, three runs
service_healthy, pg_isready -U postgres, no timings set30.7 sConnected first time
Same check with start_period: 30s, start_interval: 1s1.8 sConnected first time
Same, plus a 15-second script in /docker-entrypoint-initdb.d1.7 sConnection refused for 14.52 s, 136 failures
pg_isready -h 127.0.0.1 -U postgres, same init script17.7 sConnected first time

Plain depends_on with existing data won the race on every run, against a small database on an idle server. That is why the bug is so often reported as "only on first install" or "only after a reboot": the race is lost when the database is slow to start, which is exactly when it is doing crash recovery, replaying a large write-ahead log, or initializing for the first time.

The 30.7-second row is the cost of the defaults. The fifth row is the trap.

The pg_isready trap: healthy before it is ready#

On first boot, the official Postgres image does not start the real server straight away. Its docker-entrypoint.sh runs initdb, then starts a temporary server with -c listen_addresses='', creates the database, runs every script in /docker-entrypoint-initdb.d, stops the temporary server, and only then execs the real one. An empty listen_addresses means the temporary server listens on the Unix socket and on no TCP port at all.

pg_isready -U postgres with no -h connects through that socket. The PostgreSQL documentation gives exit status 0 when "the server is accepting connections normally", and the temporary server is. In our run, the first health log entry, 1.17 seconds after the container started, read /var/run/postgresql:5432 - accepting connections. Compose released the app, the app connected over the network to a server with no TCP listener, and it got Connection refused until the real server came up at the end of the 15-second script.

Add -h 127.0.0.1 and the same check goes over TCP, where the temporary server is invisible. Our app then started 17.7 seconds after the database, after the real server logged database system is ready to accept connections, and connected first time.

Without an init script the window is short: on our runs the temporary server lived for about 0.1 seconds, so a socket check rarely lands inside it. Restoring a dump on first boot, or any app whose image ships init scripts, makes it long. The -h costs nothing, so use it everywhere.

The healthcheck that works#

yaml
services:
  db:
    image: postgres:18-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 60s
      start_interval: 1s

  app:
    image: example/app:2.3.1
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
        restart: true

What each timing does, from the Dockerfile reference:

FieldDefaultWhat it controls
interval30sTime between checks after the start period. The first check also waits one interval, unless a start period is set
timeout30sA check that runs longer counts as a failure and is killed with SIGKILL
retries3Consecutive failures before the container is unhealthy
start_period0sGrace time for startup. Failures inside it do not count, and the first success ends it
start_interval5sTime between checks during the start period. Needs Docker Engine 25.0 or later

The pairing that matters is start_period with start_interval. During startup the check runs every second, so the app starts about a second after the database is ready, and failures in that window are not counted against retries. After the first success, checks slow to interval, so a steady-state check every 30 seconds costs almost nothing. Size start_period to the slowest first boot you expect, including any restore in an init script. It is a ceiling on how long failures are forgiven, not a delay.

What the error looks like when the check never passes#

If the database's healthcheck fails retries times in a row, Compose stops waiting:

text
 Container stack-db-1  Waiting
 Container stack-db-1  Error
dependency failed to start: container stack-db-1 is unhealthy

docker compose up -d exited with status 1, and the app container was left in Created. It stays there. Moby's restore code only restarts containers that have a restart policy and HasBeenStartedBefore, so a container that never ran is not started on the next boot either. Fix the cause and run docker compose up -d again.

The cause is usually the check, not the database. Read what the check printed:

bash
docker inspect -f '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{end}}' stack-db-1

A check that calls a tool the image does not ship fails forever. On alpine, a curl -f http://localhost/ check went unhealthy with the output /bin/sh: curl: not found, and from docker compose ps that looks exactly like a broken service. Use whatever the image ships: pg_isready in Postgres images, wget in many Alpine-based ones.

What restart: true does, and what it does not#

restart: true under a dependency is a Compose feature, added in 2.17.0. Docker's documentation says it restarts the dependent service "if db is updated or restarted due to an explicit Compose operation, for example docker compose restart". We tested both ways of restarting the database:

CommandDependent restarted, restart: trueDependent restarted, without it
docker compose restart dbYesNo
docker restart stack-db-1NoNo

Anything that bypasses Compose bypasses restart: true, and a reboot, covered next, bypasses Compose. It is worth setting for apps that keep a connection pool and never reconnect, but it is not a recovery mechanism.

After a reboot, depends_on does nothing#

This is what most guides leave out. depends_on is read by Compose, the client. When the host reboots, Compose is not running, and the Docker daemon starts the containers on its own, according to each one's restart policy.

Moby's daemon/daemon.go shows how. On startup it collects every container with a restart policy and starts them in parallel goroutines. The only ordering is a "best effort to wait for children": legacy links in 28.1.1, and links or a container whose network namespace is shared in docker-v29.9.1. Even then it waits for the other container's start to complete, not for it to be healthy, and gives up after 5 seconds. Compose does not create links for depends_on: our app container's HostConfig.Links was empty.

So after a reboot, your app and your database start side by side, with nothing holding the app back. Three defenses, in the order we would use them:

  1. Let the app retry. If the app retries its database connection on startup, a reboot costs nothing. If it exits instead, restart: unless-stopped brings it back until it connects. That is a crash loop, but a short one.
  2. Bring the stacks up with Compose after boot. A systemd unit that runs docker compose up -d in each stack's directory after docker.service re-applies the conditions on every boot. The cost: those services need restart: "no", or the daemon starts them first anyway, and then nothing restarts them if they crash later.
  3. Use systemd ordering instead. Podman with Quadlet turns containers into units with real dependencies; Docker vs Podman covers what that costs you on the Compose side.

Updates are the other moment a stack restarts, often with a schema migration in the new image. An update strategy that does not lose data covers why to take a backup first.

When this answer stops applying#

  • Compose and Engine versions. start_interval needs Engine 25.0 or later, restart: under depends_on needs Compose 2.17.0, and required: false needs 2.20.0. On anything older, the field is rejected or ignored.
  • podman-compose. It handles service_healthy inconsistently; Docker vs Podman has the detail. Nothing here was tested on it.
  • A database on another host or a shared instance. depends_on only sees services in the same project. A single Postgres serving several stacks, which One shared Postgres or one per app argues against, gives each app nothing to wait on.
  • A 137 or a crash loop that is not about the database. If the app is dying for another reason, What Docker exit code 137 means covers how to tell an out-of-memory kill from a stop timeout.

What to do next#

Search your compose files for pg_isready without -h and add -h 127.0.0.1. Add start_period and start_interval: 1s to every database healthcheck, and switch every depends_on: [db] to condition: service_healthy. Then reboot the server once, deliberately, and watch which containers crash-loop: those are the ones that do not retry, and no compose setting will save them on the next unplanned restart. The per-stack layout these snippets fit into is in Docker Compose conventions, and Docker Engine lists the Engine and Compose versions the fields above need.

Questions#

What is the difference between service_started and service_healthy?

service_started, the default and what the short list syntax means, lets the dependent start as soon as the dependency's container has started. service_healthy holds it until the dependency's healthcheck has passed. In our test a first-boot Postgres refused TCP connections for 1.1 to 1.35 seconds after service_started released the app, and service_healthy closed that gap. A third condition, service_completed_successfully, waits for a one-shot container such as a migration job to exit 0.

Why does docker compose up take 30 seconds with a healthcheck?

Because the first check runs one interval after the container starts, and the default interval is 30 seconds. The database may be ready in one second, but nothing looks until second thirty. Our app container started 30.7 seconds after its database with only test set. Add start_period and start_interval: 1s (Engine 25.0 or later), and the first check runs about one second after start.

What does 'dependency failed to start: container is unhealthy' mean?

The dependency's healthcheck failed retries times in a row, so Compose gave up and exited with status 1. The dependent container is created but never started, and it stays that way: Docker's restart logic skips containers that have never run. Read the check's own output with docker inspect -f '{{json .State.Health}}' <container>, fix the check or the service, and run docker compose up -d again.

Does depends_on work after a reboot?

No. depends_on is read by Compose, and on a reboot Compose is not running. The Docker daemon restarts every container with a restart policy in parallel. Its source waits only for legacy links and shared network namespaces, for at most five seconds, and only until they are running. Compose creates no links for depends_on, so the app has to survive a database that is not ready yet.

Should I use pg_isready or psql in a Postgres healthcheck?

pg_isready -h 127.0.0.1 is enough for ordering. It asks the server over TCP whether it accepts connections, and the PostgreSQL docs say no correct user, password or database is needed for that. Use psql -h 127.0.0.1 -c 'select 1' with real credentials only if you also want the check to fail when the application's database or role is missing.

Sources#

Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.