docker compose depends_on does not wait for your database: fix "connection refused" on startup with healthchecks
Your app crashes with ECONNREFUSED or "could not connect to server" on docker compose up, then works on the second try. Why depends_on only waits for the container to start, the healthcheck fix, the service_healthy trap, and a free VS Code extension that flags it.

You run docker compose up, the logs scroll, and a second later your API container is dead:
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_onmeanscondition: service_started. Usecondition: service_healthyand give the database ahealthcheck:(for examplepg_isready). The official database images ship noHEALTHCHECK, soservice_healthywithout one fails instead of waiting. 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:
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 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, 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, 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:
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. 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: 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 is one example with --wait).
The manual fix
Give each stateful service a real readiness probe and make dependents wait for it:
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, wherePOSTGRES_USERis actually set.Set
start_period. Without it, failed probes during a slow first boot count towardretries, and a database that is busy running init scripts can be marked unhealthy before it ever had a chance.Keep
timeoutbelowinterval. 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 pingandrabbitmq-diagnostics -q pingall 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 to catch that while you edit.
code --install-extension jaytankdev.readywait
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_onblock to long form, setscondition: service_healthyfor the database and keepsservice_startedfor the other entries. If the database has no healthcheck, it inserts one withinterval: 5s,timeout: 5s,retries: 10andstart_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 viafile:inside the workspace.For locally built services, the Dockerfile named by
build:is read, and aHEALTHCHECKthere 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
dockeraccess, so an image from a registry that defines its ownHEALTHCHECKstill gets RW002. Disable the rule or add an explicithealthcheck:to silence it.The YAML reader is line-based. Anchors, merge keys (
<<:) andinclude:are not followed, and-ffile combinations from scripts or Makefiles are not detected.The redis probe is a liveness check, not an auth check. It accepts a
NOAUTHreply so it works withrequirepass; add-ayourself 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
Install:
code --install-extension jaytankdev.readywaitSource (MIT): 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.
About the author
- Cloud & Platform Engineer focused on building systems that scale and endure. I explore how infrastructure, automation, and engineering practices come together to support modern software teams.
Comments
Join the discussionNo comments yet. Be the first to comment.


