DockerDocker

Connection refused between Compose services on startup

depends_on waits for the container to start, not for the service inside it to be ready. How to use condition service_healthy, and why the app should retry anyway.

medium fix6 min read

the docker error
dial tcp 172.18.0.3:5432: connect: connection refused

psycopg2.OperationalError: could not connect to server: Connection refused
        Is the server running on host "postgres" (172.18.0.3) and accepting TCP connections on port 5432?

Error: getaddrinfo ENOTFOUND postgres

Do this first3 steps

Run these in order. Each one tells you what its output means before you change anything.

  1. 1

    Confirm the dependency is up but not yet ready

    docker compose ps

    A dependency showing "Up" without a health status means Compose considers depends_on satisfied while the process inside may still be initialising. That gap is the whole problem.

  2. 2

    Add a healthcheck to the dependency and gate on it

    docker compose config | grep -A6 healthcheck

    depends_on with condition service_healthy waits for the healthcheck to pass rather than for the container to exist. Without a healthcheck defined, that condition cannot be used.

  3. 3

    Verify the wait actually happens

    docker compose up --abort-on-container-exit

    With a correct healthcheck the dependent service should not start until the dependency reports healthy. If it still starts immediately, the condition is missing or the healthcheck is trivially passing.

All 8 sections

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:

ErrorMeaning
connection refusedName resolved, nothing listening yet. This page
ENOTFOUND / no such hostName did not resolve. Wrong service name or network
connection timed outSomething 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:

ConditionWaits for
service_startedThe container started. The old default
service_healthyThe healthcheck passes
service_completed_successfullyThe 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

  1. docker compose ps. Dependency Up, yours exited → a readiness race.
  2. connection refused is this page. ENOTFOUND is networking.
  3. Add a healthcheck to the dependency.
  4. Change depends_on to map form with condition: service_healthy.
  5. Set start_period so a slow start does not exhaust retries.
  6. Migrations → a separate service plus service_completed_successfully.
  7. Make the app retry its own connections. Compose conditions only cover startup.
  8. ENOTFOUND → check the service key, not container_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.

Reference and practice

Learn the underlying concept

Other Docker errors