# docker compose depends_on does not wait for your database: fix "connection refused" on startup with healthchecks

You run `docker compose up`, the logs scroll, and a second later your API container is dead:

```plaintext
api-1  | Error: connect ECONNREFUSED 172.18.0.2:5432
api-1  | psql: error: could not connect to server: Connection refused
api-1  |   Is the server running on host "db" and accepting TCP/IP connections on port 5432?
api-1 exited with code 1
db-1   | LOG:  database system is ready to accept connections
```

Read the last line again. Postgres became ready *after* the app gave up. You run `docker compose up` a second time and everything works, because the database volume is already initialized and Postgres starts faster. So it feels random, it breaks CI once a week, and new team members hit it on day one.

The compose file has `depends_on: [db]`, which looks like it should prevent exactly this. It does not. **Docker Compose** `depends_on` **waits for the database container to start, not for the database to be ready**, and that gap is where "connection refused" on startup comes from.

> **TL;DR:** short-form `depends_on` means `condition: service_started`. Use `condition: service_healthy` and give the database a `healthcheck:` (for example `pg_isready`). The official database images ship no `HEALTHCHECK`, so `service_healthy` without one fails instead of waiting. [ReadyWait](https://marketplace.visualstudio.com/items?itemName=jaytankdev.readywait) is a free VS Code extension that flags both mistakes in your compose files and fixes them in one click.

### Why depends\_on does not wait for the database

This is the compose file almost every tutorial starts with:

```yaml
services:
  api:
    build: .
    depends_on:
      - db
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
```

The list form of `depends_on` is shorthand for `condition: service_started`. Compose creates and starts `db`, sees the container running, and immediately starts `api`. The [startup order guide](https://docs.docker.com/compose/how-tos/startup-order/) says it plainly: Compose waits until a container is running, not until it is ready.

"Running" happens within a second. "Ready" is much later. On a fresh volume the Postgres entrypoint runs `initdb`, starts a temporary server, runs your `/docker-entrypoint-initdb.d` scripts, shuts that server down and starts the real one. MySQL and MariaDB do the same dance. Kafka waits for its controller, Elasticsearch builds its cluster state, RabbitMQ boots Erlang. Anything your app does in that window ends in `ECONNREFUSED`, `could not connect to server`, `Can't connect to MySQL server on 'db'` or a Redis `Connection refused`.

It is one of the oldest questions in the Compose tracker: [docker/compose#374](https://github.com/docker/compose/issues/374), from the Fig days, asks how to delay a container until a slow MySQL is up. The same story shows up in [docker-library/postgres#297](https://github.com/docker-library/postgres/issues/297), where the web service fails with "Connection refused - Is the server running on host" while the Postgres logs show initialization still in progress.

### The second trap: service\_healthy with no healthcheck

The fix everyone lands on is the long form of [depends\_on](https://docs.docker.com/reference/compose-file/services/#depends_on):

```yaml
    depends_on:
      db:
        condition: service_healthy
```

That tells Compose to wait until `db` reports healthy. The catch is that "healthy" only exists if the service has a [healthcheck](https://docs.docker.com/reference/compose-file/services/#healthcheck). The official `postgres`, `mysql`, `mariadb`, `mongo`, `redis` and `rabbitmq` images do not define a `HEALTHCHECK` instruction. That is a deliberate choice, explained in the [Docker official images FAQ](https://github.com/docker-library/faq): what "healthy" means differs per user, and a slow first boot can get a container killed by a check that is too eager.

So if you switch to `service_healthy` and forget the `healthcheck:` block, you have not made startup wait. You have made it fail, with errors like `container for service "db" has no healthcheck configured` or `dependency failed to start: container db-1 is unhealthy`. The same thing happens when a healthcheck exists but is switched off with `disable: true` or `test: ["NONE"]`, and the tooling around it is not always friendly either ([docker/compose#13522](https://github.com/docker/compose/issues/13522) is one example with `--wait`).

### The manual fix

### Give each stateful service a real readiness probe and make dependents wait for it:

```yaml
services:
  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER:-postgres}"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s
  redis:
    image: redis:7
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping 2>&1 | grep -qE 'PONG|NOAUTH'"]
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s
```

A few details that matter:

*   `$$` **is not a typo.** Compose interpolates `${...}` from your shell before the container ever sees it. `$$` escapes it, so the variable is resolved inside the container, where `POSTGRES_USER` is actually set.
    
*   **Set** `start_period`**.** Without it, failed probes during a slow first boot count toward `retries`, and a database that is busy running init scripts can be marked unhealthy before it ever had a chance.
    
*   **Keep** `timeout` **below** `interval`**.** If a probe can run longer than the gap between probes, they overlap and the numbers stop meaning what you think.
    
*   **Pick a probe that tests connections.** `pg_isready -h 127.0.0.1` (TCP: over the Unix socket it can report ready during the init phase, before the real server starts), `mysqladmin ping -h 127.0.0.1` (TCP, the path your app uses), `mongosh --eval 'db.adminCommand({ping: 1})'`, `redis-cli ping` and `rabbitmq-diagnostics -q ping` all ship inside the official images, so you do not need to install anything.
    

And one more, which no compose setting replaces: your app should still retry its first connection with a backoff. Healthchecks fix startup order on your laptop and in CI. In production the database can restart under a running app, and code that crashes on the first `ECONNREFUSED` will crash there too.

### ReadyWait: find it in the editor

Knowing the fix is easy. Noticing that the fifth compose file in a monorepo still has `depends_on: [db]`, or that someone added `service_healthy` against a Redis with no healthcheck, is the hard part. I built [ReadyWait](https://marketplace.visualstudio.com/items?itemName=jaytankdev.readywait) to catch that while you edit.

```plaintext
code --install-extension jaytankdev.readywait
```

![](https://cdn.hashnode.com/uploads/covers/6a548baa47c6120a79511a69/d495397a-e586-4429-8b65-1bcff6a92a0d.png align="center")

It scans every `compose*.yml|yaml` and `docker-compose*.yml|yaml` file in the workspace, shows findings in the Problems panel on the exact `depends_on` line, and puts a count in the status bar. Click the count (or run **ReadyWait: Show depends\_on & Healthcheck Report**) for a Markdown table of every service, its healthcheck status, its dependencies and its findings.

The rules:

| Code | Severity | Flags |
| --- | --- | --- |
| RW001 | Warning | Short-form `depends_on` or `condition: service_started` on a slow-to-be-ready service: postgres, mysql, mariadb, mongo, redis, valkey, kafka, zookeeper, rabbitmq, elasticsearch, opensearch, cassandra, clickhouse, minio, localstack, keycloak |
| RW002 | Warning | `condition: service_healthy` on a service with no `healthcheck:` in any compose file (or a disabled one) and no `HEALTHCHECK` in its local Dockerfile |
| RW003 | Information | A healthcheck with no `start_period`, or a `timeout` longer than its `interval` |
| RW004 | Information | `condition: service_completed_successfully` on a service with `restart: always` or `unless-stopped`: a one-shot job that restarts forever is contradictory |

The Quick Fixes do the boring part:

*   **RW001** rewrites the `depends_on` block to long form, sets `condition: service_healthy` for the database and keeps `service_started` for the other entries. If the database has no healthcheck, it inserts one with `interval: 5s`, `timeout: 5s`, `retries: 10` and `start_period: 30s`.
    
*   **RW002** inserts the healthcheck into the target service.
    

Healthcheck commands are only generated where the tool ships in the official image: `pg_isready -h 127.0.0.1` for postgres, `mysqladmin ping -h 127.0.0.1` for mysql, `healthcheck.sh --connect --innodb_initialized` for the official mariadb image (`mysqladmin` for tags before 10.4), `mongosh` (falling back to `mongo`) for mongo, `redis-cli ping` / `valkey-cli ping` (matching `PONG` or `NOAUTH`, so it works with `requirepass` and does not pass while the dataset is still loading), `rabbitmq-diagnostics -q ping` and `cqlsh -e 'describe keyspaces'` for cassandra. For kafka, elasticsearch, opensearch, clickhouse, minio, localstack, keycloak and zookeeper the right probe depends on the image flavor and security settings, so ReadyWait converts the condition and leaves the healthcheck to you. RW002 then reminds you it is still missing.

ReadyWait also has its own icon in the activity bar with a badge. Its **Compose Readiness** panel names each relation (for example `RW001 api -> db`), groups them by compose file, and has inline **Use service\_healthy** and **Add healthcheck** buttons. Override files are merged the way `docker compose` merges `compose.yaml` and `compose.override.yaml`; other files such as `compose.prod.yaml` are checked as overlays on the base.

It reads your project the way Compose does, as far as a static tool can:

*   Files in the same folder are merged like `compose.yaml` + `compose.override.yaml`, so a healthcheck that lives in the override file counts.
    
*   `extends:` is followed best-effort, in the same file or via `file:` inside the workspace.
    
*   For locally built services, the Dockerfile named by `build:` is read, and a `HEALTHCHECK` there counts as a healthcheck.
    

Settings: `readyWait.enable`, `readyWait.statefulImages` (the image list for RW001, matched on the last path segment with the tag ignored, so `bitnami/postgresql` and `docker.io/library/postgres:16` both match), `readyWait.disabledRules` (for example `["RW003"]`) and `readyWait.maxFiles` (default 200).

### Privacy and safety

ReadyWait is fully static. It only reads files in your workspace: no network access, no telemetry, no child processes and no `docker` calls. Because it never runs anything, it also works in untrusted workspaces.

### Limitations

Worth knowing before you rely on it:

*   **Image healthchecks are not inspected.** There is no registry or `docker` access, so an image from a registry that defines its own `HEALTHCHECK` still gets RW002. Disable the rule or add an explicit `healthcheck:` to silence it.
    
*   **The YAML reader is line-based.** Anchors, merge keys (`<<:`) and `include:` are not followed, and `-f` file combinations from scripts or Makefiles are not detected.
    
*   **The redis probe is a liveness check, not an auth check.** It accepts a `NOAUTH` reply so it works with `requirepass`; add `-a` yourself if you want an authenticated probe.
    
*   It checks readiness at startup. It does not make your app resilient to a database that restarts later, which is what the retry advice above is for.
    

### FAQ

**Is** `depends_on` **useless then?** No. It controls start order and it is what `condition: service_healthy` hangs off. The short form just does not wait for readiness.

**Why not use wait-for-it.sh or dockerize?** They work, but they add a script or binary to your image and a wrapper around your entrypoint. A compose healthcheck keeps the waiting logic in one place and reports status in `docker compose ps`.

**Does** `restart: on-failure` **fix it?** It hides it. The app crashes, restarts, and eventually connects. You get noisy logs, slower startup and a masked real failure.

**Will it complain about my custom Postgres image?** Only if its name matches `readyWait.statefulImages`. You can shrink or extend that list per workspace.

**Does it run Docker?** No. It never executes anything, it only reads files.

### Try it

*   Marketplace: [ReadyWait](https://marketplace.visualstudio.com/items?itemName=jaytankdev.readywait)
    
*   Install: `code --install-extension jaytankdev.readywait`
    
*   Source (MIT): [github.com/jay-tank/readywait](https://github.com/jay-tank/readywait)
    

If it flags something it should not, or misses a compose layout you use, open an issue. False positives are bugs and I want to hear about them.
