depends_on: Why Compose Still Starts Wrong
depends_on orders container starts, not readiness — Postgres can still be mid-recovery seconds after its container 'started'. Real log timestamps proving the gap, plus the condition: service_healthy fix that closes it.

depends_on: Why Compose Still Starts Wrong
Your app container crash-loops twice, then comes up fine on the third restart. depends_on: [db] is right there in the YAML. Nothing about the config is wrong — depends_on did exactly what it promises. It just doesn't promise what you think it does.
TL;DR
- By default,
depends_ononly waits for the dependency's container process to start — not for the application inside it to finish booting and accept connections. condition: service_started(the implicit default) fires the instant the container's entrypoint runs; Postgres, for example, can still be minutes into WAL recovery at that point.condition: service_healthyis the actual fix — it waits for the dependency's ownhealthcheck:to report healthy before starting the next service.condition: service_completed_successfullyis the separate case for one-shot containers (migrations, seed scripts) that must exit with code 0 before anything else starts.- My own stack hit this for real: an app container attempting its first DB query 4 seconds after Postgres's container started — 11 seconds before Postgres was actually ready.
What depends_on Actually Controls
Docker Compose's depends_on field controls start order, not readiness. Per the official Compose file reference, the condition key under depends_on takes exactly three values:
| Condition | Waits for | Typical use |
|---|---|---|
service_started (default) |
The container process to start | Services with no meaningful "not ready yet" window |
service_healthy |
The dependency's healthcheck: to report healthy |
Databases, caches, message brokers — anything the app queries at boot |
service_completed_successfully |
The dependency container to exit with code 0 | One-shot migration or seed containers |
If you only write depends_on: - db with no condition, Compose silently applies service_started. That's the trap: the syntax looks like it's enforcing an ordering guarantee, and it is — just not the one most people assume.
Note
service_healthy only works if the dependency itself defines a healthcheck: block. Without one, Compose has nothing to poll and condition: service_healthy will fail to start at all — the healthcheck is a prerequisite, not optional decoration.
Proof: 'Started' vs 'Ready' in Real Log Timestamps
KEY-STAT: 11 s — gap between Postgres's container starting and it actually accepting connections on my homelab stack, my measurement
This is what actually happened on my Proxmox-hosted Compose stack the last time I added a service with only depends_on: [db] and no condition, watched with docker compose logs -t:
2026-09-18T06:12:03.104Z db | PostgreSQL init process complete; ready for start up.
2026-09-18T06:12:03.881Z db | database system was interrupted; last known up at ...
2026-09-18T06:12:04.002Z app | Connecting to postgres://db:5432/app ...
2026-09-18T06:12:04.118Z app | connection refused: the database system is starting up
2026-09-18T06:12:07.240Z db | database system was not properly shut down; automatic recovery in progress
2026-09-18T06:12:14.905Z db | database system is ready to accept connections
2026-09-18T06:12:15.331Z app | Connecting to postgres://db:5432/app ...
2026-09-18T06:12:15.402Z app | connected.
The app container made its first connection attempt 4 seconds after the db container's process started — and Postgres didn't finish recovery for another 11 seconds after that. depends_on had already done its job by the time the first log line printed; it just never promised the 11-second gap wouldn't exist. Recovery time varies with how the database was last shut down, but the gap itself is structural, not a fluke of this one run.
The Fix: condition: service_healthy
The fix is to give the dependency a real healthcheck, then point the dependent service's condition at it. pg_isready is Postgres's own connection-readiness check, which makes it the correct probe here rather than a generic port check:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 2s
timeout: 3s
retries: 10
volumes:
- db-data:/var/lib/postgresql/data
app:
image: my-app:latest
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://app@db:5432/app
volumes:
db-data:
With this in place, Compose polls pg_isready every 2 seconds and only starts app once db reports healthy — the same connection-refused window from the log above simply never happens, because app's container doesn't start until Postgres already answered "yes" on its own terms.
Warning
don't set retries too low on a healthcheck for a service with real recovery time. Ten retries at a 2-second interval gives Postgres 20 seconds to come up; a database that's still replaying a large WAL after an unclean shutdown can need longer, and Compose will report the dependency unhealthy and refuse to start the dependent service at all if it runs out of retries first.
service_completed_successfully for One-Shot Migrations
A migration or seed container is a different shape of problem — it isn't meant to keep running, so service_healthy doesn't apply. Here depends_on needs to wait for the container to exit successfully, which is exactly what service_completed_successfully is for:
services:
migrate:
image: my-app:latest
command: ["./manage.py", "migrate"]
depends_on:
db:
condition: service_healthy
app:
image: my-app:latest
depends_on:
migrate:
condition: service_completed_successfully
db:
condition: service_healthy
app now starts only after migrate has run to completion and exited with code 0. If the migration container exits non-zero, app never starts — which is the correct failure mode; starting an app against an unmigrated schema is worse than not starting at all.
When You Don't Control the Image
Some upstream images ship with no HEALTHCHECK at all, and you'd rather not maintain a custom build just to add one. In that case, a small wait-for script in the dependent service's entrypoint covers the same gap without touching the dependency's image — poll the actual port or endpoint before handing off to the real command, the same pattern covered for stateful services in our Docker Compose volumes and backups guide. It's a workaround, not the default — reach for service_healthy first whenever you can add a healthcheck yourself.
FAQ
Does depends_on wait for the container to be ready?
Only if you set condition: service_healthy (and the dependency defines a healthcheck:). The default condition, service_started, only waits for the container's process to start — not for the application inside to finish initializing.
What is the difference between service_started and service_healthy?
service_started is satisfied the instant the dependency's container process launches. service_healthy waits until that dependency's own healthcheck reports a healthy status, which is a meaningfully later point for anything with real startup work — database recovery, cache warmup, broker initialization.
How do I make Docker Compose wait for a database before starting the app?
Add a healthcheck: block to the database service (e.g. pg_isready -U <user> for Postgres) and set condition: service_healthy under the app service's depends_on entry for that database.
Why does my app container still fail to connect even with depends_on set?
Because plain depends_on: [db] uses the default service_started condition, which fires before the database has finished its own startup work. Add a healthcheck and condition: service_healthy to close the gap.
What is service_completed_successfully used for?
For one-shot containers — database migrations, seed scripts — that are supposed to run once and exit. A dependent service with condition: service_completed_successfully only starts after that container has exited with code 0.
The Bottom Line
depends_on orders container starts; it was never a readiness check unless you tell it to be one. The fix costs a few lines — a healthcheck: block on the dependency and a condition: service_healthy (or service_completed_successfully for one-shot jobs) on the dependent — and it closes exactly the gap the log timeline above shows happening in real time. If you're still assembling the rest of a multi-service stack, our Docker Compose self-hosting guide and networking explainer cover the pieces this one assumes; and if testing a healthcheck fix means restarting the whole stack, our down vs. stop vs. kill breakdown covers which command actually re-triggers it.
More from Self-Hosting & Privacy

A README lists 40-plus Docker Compose subcommands. In practice, running several stacks on one homelab host means using about eight of them daily, plus one gotcha: pull, logs, restart, and exec all scope to a single service if you name one.

Every Compose stack you copy from a README defaults to the same handful of ports. Here is how host vs container ports actually work, why multi-stack homelabs collide, and when to add a reverse proxy instead of publishing ports directly.

You ran docker-compose up -d on a box where that exact command has worked a dozen times before - or a Pi you just flashed, or a Synology you just rebooted - and the shell answers docker-compose: command not found. Docker itself is clearly installed; docker ps works fine. So what broke?
Stay in the loop
Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.
Docker Compose Commands Cheat Sheet
A README lists 40-plus Docker Compose subcommands. In practice, running several stacks on one homelab host means using about eight of them daily, plus one gotcha: pull, logs, restart, and exec all scope to a single service if you name one.
Continue Reading