Docker Compose depends_on service_healthy Not Waiting
If your dependent service starts before its dependency is ready, the cause is almost always the short depends_on syntax, which only orders container startup. To actually wait for readiness, use the long (map) syntax with condition: service_healthy and make sure the dependency defines a real, passing healthcheck. The short list form never waits.
Short syntax orders, long syntax waits
The list form of depends_on does not wait for readiness. It only guarantees that the dependency container is created and started first, then immediately starts the dependent service. Compose has no idea whether the database inside that container is accepting connections yet, so your app races ahead and crashes on the first query.
The fix is the map (long) form, where each dependency gets an explicit condition. There are three values: service_started (the default, equivalent to short syntax), service_healthy (wait until the dependency's healthcheck passes), and service_completed_successfully (wait until the dependency exits with code 0). Only service_healthy blocks startup on actual readiness.
# Short syntax - DOES NOT wait for readiness
services:
web:
depends_on:
- db
# Long syntax - waits for the healthcheck to pass
services:
web:
depends_on:
db:
condition: service_healthy
restart: true
The restart: true option (Compose 2.17.0+) restarts the dependent service whenever the dependency is restarted by an explicit Compose operation, such as docker compose restart, so connections are re-established. Note it does not respond to the container runtime's own automatic restart policy. If you are still hand-writing these blocks, the docker-compose generator can scaffold the long-form structure for you.
The dependency has no healthcheck (so it never goes healthy)
The second-most-common failure is the opposite symptom: instead of starting too early, Compose hangs forever waiting for a dependency that can never become healthy. This happens when you set condition: service_healthy but the dependency has no healthcheck defined. With no healthcheck, the container's health status stays starting indefinitely, the condition is never satisfied, and the dependent service is never created.
For Postgres, the canonical check is pg_isready, which confirms the server is actually accepting connections rather than merely that the process exists. Use CMD-SHELL so shell variable expansion works, and remember that in Compose a literal $ must be escaped as $.
services:
db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Verify the command independently before trusting it: run docker compose exec db pg_isready -U app -d app and check the container's reported state with docker inspect --format '{{.State.Health.Status}}' <container>. A healthcheck that passes in your shell but fails inside the container (wrong user, missing client binary, wrong socket path) will silently stall every dependent service.
start_period and the graduation trap
Slow-booting services need start_period, a grace window during which failing probes do not count toward the retries budget. If your database takes 25 seconds to initialize but probing begins immediately and gives up after 3 failures, the container is flagged unhealthy before it ever had a chance, and service_healthy dependents stall on a dependency that Compose has already written off.
There is a subtle behavior worth knowing: if any single probe succeeds during the start period, the container immediately graduates out of the grace window, and from that point every consecutive failure counts toward retries. So a service that flickers ready-then-busy during boot can still be marked unhealthy. On Docker Engine 25.0+ (and Compose 2.20.2+) you can add start_interval to probe more frequently during startup without polling aggressively for the container's whole life. Note that start_interval is ignored unless start_period is non-zero.
| Option | Default | Purpose |
|---|---|---|
| interval | 30s | Time between probes after startup |
| timeout | 30s | A probe slower than this counts as a failure |
| retries | 3 | Consecutive failures before unhealthy |
| start_period | 0s | Grace window where failures are not counted |
| start_interval | 5s | Probe frequency during start_period (Engine 25.0+) |
docker compose run ignores the wait (and other edge cases)
A frequent surprise: docker compose run does not honor health conditions the way docker compose up does. This is a long-standing, well-documented inconsistency (tracked in docker/compose issues such as #4369 and #7681): run starts the dependencies but does not wait for their healthchecks before launching the one-off command. If your one-off command races the database, it will fail even though the configuration looks correct.
Workarounds: bring dependencies up first with docker compose up -d db and wait for it to report healthy, then invoke docker compose run; clean up dangling one-off containers with docker compose rm beforehand; and write healthchecks that do not falsely fail in a run context. Separately, sharing a container filesystem with volumes_from on a dependent service has been reported to make Compose start that service early and ignore service_healthy; if you suddenly see startup races after adding a shared-volume reference, that is a prime suspect to remove and re-test.
service_completed_successfully for migrations
For one-shot jobs like database migrations or seed scripts, you do not want service_healthy (a migration that exits is not "healthy") and you do not want your app racing the schema change. Use service_completed_successfully, which waits for the dependency to exit with code 0. Chain all three: the migration waits for the DB to be healthy, and the app waits for the migration to finish cleanly.
services:
db:
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
retries: 5
migrate:
image: myapp:latest
command: ["npm", "run", "migrate"]
depends_on:
db:
condition: service_healthy
app:
image: myapp:latest
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
Two caveats: your migration tool must exit non-zero on failure, or Compose treats a broken migration as success and starts the app anyway. And if a chained one-shot step fails, Compose can hang reporting the failure without finishing the stack, so watch your exit codes carefully. Once your file is correct, run docker compose config to validate the merged result, and use the SQL formatter to keep migration scripts readable before you commit them.
Frequently Asked Questions
The short list form of depends_on only orders container startup; it does not wait for the service inside to be ready. Use the long syntax with condition: service_healthy and define a healthcheck on the dependency.
The dependency probably has no healthcheck defined, so its status stays 'starting' and the condition is never satisfied. Add a healthcheck (for example pg_isready for Postgres) so the container can report healthy.
The run command does not wait for health conditions the way up does; it starts dependencies but launches your one-off command immediately. Bring dependencies up first with docker compose up -d and wait for healthy, then run the command.
Use condition: service_completed_successfully, which waits for the dependency container to exit with code 0. Make sure your migration tool exits non-zero on failure so a broken migration does not let the app start.