Short answer: plain depends_on only waits until the dependency's container has started, not until the process inside is ready. To make the app wait for Postgres, give the database a healthcheck and use depends_on with condition: service_healthy. Then add a connection retry in the app anyway, because a healthcheck narrows the race but cannot close it.
What does the failure look like?
You run docker compose up on a fresh checkout. The database container starts, the API container starts a moment later, and the API dies:
api-1 | psycopg2.OperationalError: connection to server at "db" (172.19.0.2), port 5432 failed: Connection refused
api-1 | Is the server running on that host and accepting TCP/IP connections?
db-1 | PostgreSQL init process complete; ready for start up.
Run it again and it works, because the data volume now exists and Postgres boots in a second instead of ten. That "works on the second try" pattern is the signature of a startup race. Teammates on a clean clone hit it, and CI hits it every time.
The compose file that causes it usually looks like this:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
api:
build: .
depends_on:
- db
Short-form depends_on controls start order. Compose starts db, sees the container is running, and starts api. It never asks whether Postgres is listening.
Takeaway: "container running" and "service ready" are different states, and short-form depends_on only knows the first.
How do you make Compose wait for Postgres?
Define a healthcheck on the database and make the dependency conditional on it:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: example
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
interval: 3s
timeout: 3s
retries: 20
start_period: 10s
api:
build: .
depends_on:
db:
condition: service_healthy
Compose now holds api in "created" state until db reports healthy. If the database never becomes healthy within the retries, docker compose up exits with dependency failed to start: container db is unhealthy, which is a far better error than a stack trace from your app.
Takeaway: the long-form depends_on with condition: service_healthy turns "start order" into "readiness order".
Why does the healthcheck pass too early on a fresh volume?
This is the part most tutorials skip, and it bit me. The official postgres image, on the very first start with an empty data directory, runs its init scripts against a temporary server. As I understand the entrypoint script, that temporary server is configured to listen only on the Unix socket, not on TCP. When it finishes, it shuts down and the real server starts.
A healthcheck of just pg_isready -U app talks to the local Unix socket by default. During the init phase it can report "accepting connections" even though no TCP listener exists yet, and the server is about to restart. The result is a database that looks healthy, an app that connects over TCP to db:5432, and then either Connection refused or the database system is shutting down.
Adding -h 127.0.0.1 forces the check over TCP, which only succeeds once the final server is up. That one flag is why the example above uses it. If you use the socket form and only see failures on first boot (never on later boots with an existing volume), this is the cause.
| Healthcheck command | Passes during init temp server? | Safe to gate the app on? |
|---|---|---|
pg_isready -U app |
Can, via Unix socket | No, flaky on first boot |
pg_isready -h 127.0.0.1 -U app -d app |
No, needs TCP listener | Yes |
psql -h 127.0.0.1 -U app -d app -c 'select 1' |
No, and proves auth works | Yes, slightly heavier |
Takeaway: check readiness over the same transport your app will use, or the healthcheck can lie on first boot.
What about migrations that must finish before the app starts?
Waiting for the database is half of the problem. If a migration job must complete before the API serves traffic, Compose has a condition for that too:
services:
migrate:
build: .
command: ["python", "-m", "alembic", "upgrade", "head"]
depends_on:
db:
condition: service_healthy
api:
build: .
depends_on:
migrate:
condition: service_completed_successfully
db:
condition: service_healthy
service_completed_successfully waits for the dependency to exit with code 0. If the migration fails, the API never starts. That is the behavior you want in a dev stack and in CI. (Alembic is just the example here; any one-shot command works.)
Takeaway: model one-shot setup work as its own service and gate on service_completed_successfully, instead of chaining shell commands in the app's entrypoint.
Do you still need retries in the app?
Yes. The healthcheck only governs the initial docker compose up. It does nothing when Postgres restarts later, when you run the app outside Compose, or in production on an orchestrator that has its own readiness semantics. An app that exits on its first failed connect is fragile everywhere except your laptop.
A minimal retry loop in Python:
import time
import psycopg2
def connect_with_retry(dsn: str, attempts: int = 10, base_delay: float = 0.5):
for attempt in range(attempts):
try:
return psycopg2.connect(dsn, connect_timeout=3)
except psycopg2.OperationalError:
if attempt == attempts - 1:
raise
time.sleep(min(base_delay * 2 ** attempt, 5))
Keep the loop bounded and let the final error surface, so a genuinely broken config fails loudly instead of retrying forever. Pair it with restart: on-failure on the service if you want Compose to give the app another chance.
Which approach should you pick?
| Approach | Handles first-boot race | Handles later DB restarts | Main drawback |
|---|---|---|---|
Short depends_on
|
No | No | Only start order |
service_healthy + healthcheck |
Yes | No | Compose-only; healthcheck must be written correctly |
wait-for-it style script in entrypoint |
Mostly | No | Extra script to maintain; TCP-open is not the same as ready |
| App-level retry with backoff | Yes | Yes | Needs a code change in every service |
If you live in Docker Compose for local development and CI, Docker Compose's service_healthy condition is the cleanest first fix because it needs no code changes. The durable answer is both layers: the healthcheck for a clean startup log, and retries in the app for everything the healthcheck cannot see. A drawback of leaning only on the healthcheck: it adds seconds to every cold start, and a too-tight retries value will mark a slow CI runner's database as unhealthy.
FAQ
Why does docker compose depends_on not wait for the database?
Short-form depends_on only waits for the dependency container to start, not for the process inside to be ready. Use condition: service_healthy with a healthcheck on the database.
What is the right pg_isready healthcheck for the Postgres Docker image?
Use pg_isready -h 127.0.0.1 -U <user> -d <db>. Forcing TCP avoids passing during the image's first-boot init phase, when only a temporary socket-only server is running.
Does depends_on condition service_healthy work with docker compose v2?
Yes. It is part of the Compose Specification supported by Docker Compose v2, as of late 2026, and it is not available in the legacy v1 file format with version: "2"/"3" semantics.
Bottom line
If your stack runs in Docker Compose, add a TCP-based pg_isready healthcheck and gate dependents on service_healthy, then put one-shot migrations in their own service with service_completed_successfully. Keep a bounded retry with backoff in the app so restarts and non-Compose environments behave too. Skip wait-for-it scripts unless you cannot use healthchecks, since they only prove a port is open.
Top comments (1)
The TCP detail is the part I found most useful, because a healthcheck that passes over the Unix socket can hide the exact failure the app will hit. Do you tune the healthcheck retries differently for a fresh volume versus an existing one, or do you keep one conservative setting for both?