depends_on controls start order, not readiness. Compose starts the dependency's container first and then immediately starts yours. A Postgres container takes several seconds to initialise, and during that window it is running and refusing connections.
So the classic Compose file is wrong in a way that only fails sometimes:
services:
api:
build: .
depends_on:
- postgres # starts postgres first, waits for nothing
postgres:
image: postgres:16
On a warm machine the API might win the race. On a cold start, or in CI, it does not.
Confirm the shape of the failure
docker compose ps
NAME IMAGE STATUS
api myapp Exited (1)
postgres postgres:16 Up 8 seconds
The dependency is up, yours exited. That is the race.
Distinguish it from a networking problem by the error text:
| Error | Meaning |
|---|---|
connection refused | Name resolved, nothing listening yet. This page |
ENOTFOUND / no such host | Name did not resolve. Wrong service name or network |
connection timed out | Something is dropping packets, usually a firewall |
connection refused is good news in a sense: DNS worked, so the services are on the same network and the name is right.
Fix 1: Healthchecks and service_healthy
services:
postgres:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
api:
build: .
depends_on:
postgres:
condition: service_healthy
Note the depends_on syntax changes from a list to a map when you add conditions. Mixing the two forms is a common mistake and Compose will tell you.
Three conditions are available:
| Condition | Waits for |
|---|---|
service_started | The container started. The old default |
service_healthy | The healthcheck passes |
service_completed_successfully | The container exited 0. For migrations and seeds |
start_period is the one people leave out. During it, failing checks do not count towards retries, which stops a slow-starting database being marked unhealthy before it has had a chance.
Getting the healthcheck right
pg_isready returns success as soon as the server accepts connections, which can be before the init scripts in /docker-entrypoint-initdb.d have finished. If your app expects a schema, check for it:
healthcheck:
test: ["CMD-SHELL", "psql -U postgres -d appdb -c 'SELECT 1' || exit 1"]
Common ones for other services:
# MySQL
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
# Redis
test: ["CMD", "redis-cli", "ping"]
# Any HTTP service
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/healthz || exit 1"]
CMD-SHELL runs the string through a shell, so || and pipes work. CMD is exec form with no shell. The HTTP one needs curl or wget inside the image, which distroless and slim images often lack.
Fix 2: Run migrations as a gate
services:
migrate:
build: .
command: ["./migrate", "up"]
depends_on:
postgres:
condition: service_healthy
api:
build: .
depends_on:
migrate:
condition: service_completed_successfully
postgres:
condition: service_healthy
Compose waits for migrate to exit 0. A non-zero exit stops the API starting at all, which is what you want: an app running against an un-migrated schema fails in more confusing ways later.
The real fix: retry in the application
Compose conditions only cover startup. They do nothing when the database restarts at three in the morning, or when a connection drops mid-request. An application that retries its own connection handles both, and makes the Compose configuration a convenience rather than a requirement.
import time, psycopg2
def connect(retries=30, delay=2):
for attempt in range(retries):
try:
return psycopg2.connect(host="postgres", dbname="appdb", user="postgres")
except psycopg2.OperationalError as e:
if attempt == retries - 1:
raise
print(f"db not ready ({attempt+1}/{retries}): {e}", flush=True)
time.sleep(delay)
Most frameworks and connection pools do this already. Check before writing it yourself.
restart: unless-stopped is the crude version: let the container die and be restarted until the dependency is up. It works, and it fills the logs with crashes and makes a genuine startup bug indistinguishable from a race.
ENOTFOUND is a different problem
If the name does not resolve at all, the two services are not on the same network, or the name is wrong.
docker compose exec api getent hosts postgres
docker network inspect $(docker compose ps -q postgres | head -1) | grep -A5 Networks
The hostname is the service key in the Compose file, not container_name. A service named postgres with container_name: db is still reachable as postgres.
Also note that Compose puts every service on a default network per project. Explicit networks: on some services and not others splits them, which produces exactly this.
A checklist
docker compose ps. DependencyUp, yours exited → a readiness race.connection refusedis this page.ENOTFOUNDis networking.- Add a
healthcheckto the dependency. - Change
depends_onto map form withcondition: service_healthy. - Set
start_periodso a slow start does not exhaustretries. - Migrations → a separate service plus
service_completed_successfully. - Make the app retry its own connections. Compose conditions only cover startup.
ENOTFOUND→ check the service key, notcontainer_name, and shared networks.
Frequently Asked Questions
Why does depends_on not wait for my database to be ready?
Because by default it only waits for the container to be started, not for the process inside to be accepting connections. A Postgres container reports as running within a second and then spends several more initialising, and during that window connections are refused. depends_on in its plain list form has always meant ordering rather than readiness. To wait for readiness you need the map form with condition: service_healthy, which requires the dependency to define a healthcheck.
What is the difference between service_started, service_healthy and service_completed_successfully?
service_started is the original behaviour: the container has been started, nothing more. service_healthy waits for the dependency's healthcheck to report healthy, which is what you want for databases and other long-running services. service_completed_successfully waits for the container to exit with status zero, which is the right condition for one-shot work such as a migration or a seed job. They can be combined, so an API can wait on both a healthy database and a completed migration.
Why did Compose wait correctly and my app still fail?
Usually the healthcheck passes earlier than real readiness. pg_isready succeeds as soon as Postgres accepts connections, which can be before the scripts in /docker-entrypoint-initdb.d have created your schema, so the app connects and then fails on a missing table. Make the check assert what your application actually needs, for example a psql -c 'SELECT 1' -d appdb against the specific database. The other cause is start_period being absent, so a slow start exhausts retries and the dependency is marked unhealthy.
Should I use a wait script like wait-for-it instead?
They work and they solve a narrower problem than people think. A wait script only covers process startup; it does nothing when the database restarts later or a connection drops mid-request. Compose healthchecks are the cleaner version of the same idea, and they need no extra binary in the image. The durable answer is retry logic in the application or its connection pool, which handles both the startup race and every subsequent blip, and most frameworks already provide it.
Why does my service name not resolve at all?
ENOTFOUND or no such host means DNS failed rather than the connection being refused, so this is a networking problem rather than a timing one. Check that you are using the service key from the Compose file as the hostname, not container_name, which does not create a DNS alias. Then confirm both services share a network: Compose puts everything on one default network per project, but adding an explicit networks: list to some services and not others splits them, and the two halves cannot resolve each other.