Docker Compose Healthcheck: Fix depends_on Race Conditions

By Anders Vik ·

Docker Compose Healthcheck: Why depends_on Still Fails

Your compose.yaml looks correct. Postgres sits under depends_on, you run docker compose up, and the app container still dies with ECONNREFUSED 127.0.0.1:5432. Restart it and it connects fine. If you've written that off as "Docker being flaky," here's what's actually going on: depends_on controls start order, not readiness.

Without a docker compose healthcheck wired to condition: service_healthy, Compose has no way to know your database is still initializing. This article covers what a healthcheck needs to test for Postgres, MySQL, MariaDB and Redis, the timing knobs that control how long Compose waits, and why a "healthy" status still doesn't guarantee your app can connect.

The Symptom: ECONNREFUSED on the First docker compose up

Here's the pattern almost everyone hits eventually. You bring the stack up cold: the database container starts, the app container starts a beat later, and the app tries to open a connection before Postgres has finished creating its default database and accepting TCP connections. The app crashes.

If you set restart: always on the app service, Compose quietly restarts it a few seconds later, by which point Postgres actually is ready. From the outside it looks like the stack "just needed a warm-up." In fact restart: always papered over a race condition instead of fixing it. If you're still getting your compose.yaml running for the first time, the site's guide to Docker Compose from first run to production is a good primer.

The same race shows up differently depending on your database. Postgres logs a clean refused connection, while MySQL and MariaDB sometimes let the app connect to a temporary init server and then drop it mid-handshake. Redis will accept the connection and then reject every command with NOAUTH if a password is set. Different symptoms, same root cause: nothing told Compose to wait.

What depends_on actually promises

The short form of depends_on, the one most tutorials show first, looks like this:

services:
  app:
    build: .
    depends_on:
      - db
  db:
    image: postgres:18

This tells Compose to start db before app, and to stop app before db on shutdown. That's the entire contract. The startup-order page in the Compose documentation states it plainly: Compose "does not wait until a container is 'ready', only until it's running." Running and ready are not the same state for a database that spends its first few seconds creating system tables, and no version of the short syntax closes that gap.

The three depends_on conditions

To get an actual readiness gate, you switch to the long form of depends_on and add a condition. There are three:

  • service_started: identical behavior to the short syntax. Waits for the container to start, nothing more.
  • service_healthy: waits for the dependency's healthcheck to report a healthy status before starting the dependent service.
  • service_completed_successfully: waits for a one-shot container, such as a database migration job, to exit with code 0.

All three are defined in the Compose Specification's services reference. That third condition is easy to forget. If you run a migration container before the app starts, you don't want a healthcheck on it (it exits on purpose), you want Compose to wait for a clean exit:

services:
  migrate:
    build: ./migrate
    depends_on:
      db:
        condition: service_healthy
  app:
    build: .
    depends_on:
      migrate:
        condition: service_completed_successfully
      db:
        condition: service_healthy

restart: true and required: false

Two smaller depends_on options solve narrower problems. restart: true tells Compose to restart the dependent service after it updates the dependency, so the app gets bounced too and reconnects instead of holding a dead connection. required: false (the default is true) downgrades a missing dependency to a warning instead of a hard failure: reasonable for an optional mail-catcher container in development, not for a database your app can't function without.

How a docker compose healthcheck actually works

Here's the part that trips people up: a healthcheck is not a Compose feature, it's an Engine feature. Compose configures it and reads the result; the Docker Engine runs the probe on a schedule and tracks the state, which moves through starting, then healthy on the first successful probe, then unhealthy after a set number of consecutive failures. That separation matters when you're debugging, because docker compose ps and docker inspect are reading Engine state.

A healthcheck's test command returns exit code 0 for healthy or 1 for unhealthy. Five settings control the timing, and all five apply whether you define the check in a Dockerfile's HEALTHCHECK instruction or in Compose's healthcheck: block:

  • interval (default 30s): how often the probe runs.
  • timeout (default 30s): how long a single probe may run before it counts as a failure. A probe that overruns is killed with SIGKILL.
  • retries (default 3): consecutive failures required before the container flips to unhealthy.
  • start_period (default 0s): a grace window during which failures don't count toward retries. Most tutorials skip it, and it's the one that saves a slow-booting database from being marked unhealthy before it's had a fair chance.
  • start_interval (default 5s, requires Docker Engine 25.0 or later): a faster probe cadence used only during the start_period, so you can check every couple of seconds at boot without hammering the container for the rest of its life.

One detail from the Dockerfile reference is easy to miss: "if a health check succeeds during the start period, the container is considered started and all consecutive failures will be counted." In plain terms, start_period isn't a fixed delay before checks begin, it's a delay before failures start counting against you. The moment a check passes, the grace period ends early.

Dockerfile HEALTHCHECK vs Compose healthcheck: which one wins

Most official images already ship a HEALTHCHECK instruction in the Dockerfile. Compose's healthcheck: block, when you set one, overrides whatever the image defines. To turn off an inherited check rather than replace it, set disable: true in Compose, or test: ["NONE"] in a Dockerfile that extends a base image. Only the last HEALTHCHECK instruction in a Dockerfile takes effect.

If you're building your own images, the Dockerfile generator picks a reasonable HEALTHCHECK probe automatically based on the runtime you choose: curl or wget for a web service, a small script for Node or Python.

Recipes that test the right thing

This is where most tutorials stop short. They hand you pg_isready or redis-cli ping without mentioning that both can report success while the service underneath is still not usable. The official Postgres image documentation is explicit: during first-run database creation, "postgres will not accept incoming connections during that time," and the temporary daemon handling init scripts "listens only on the Unix socket." A healthcheck over the Unix socket, the default for many client tools, can report success while the TCP listener your app needs is still down.

The recipes below use environment variable interpolation for usernames and passwords rather than hardcoded values. If you haven't wired up a .env file yet, the site's guide to Compose environment variables covers how Compose passes values in and the order it resolves them.

ServiceWhat breaks the naive checkThe fix
PostgresUnix-socket init server answers before TCP doesForce TCP with -h 127.0.0.1
MariaDBmysqladmin ping succeeds during install/init scriptsUse the shipped healthcheck.sh script
MySQLSame temporary-server issue, no official scriptTCP ping plus a generous start_period
Redisredis-cli ping exits 0 even on auth failureCheck the reply text, not just the exit code
HTTP appcurl or wget missing from minimal base imagesConfirm the binary exists before relying on it

Postgres

Docker-library maintainer tianon confirmed the fix in the long-running readiness issue for the official Postgres image: "Simply adding -h 127.0.0.1 to the pg_isready command line ... should be enough to detect readiness correctly," because 127.0.0.1 "won't be listening until the actual server is 'ready.'" The bare pg_isready -U postgres in most copy-paste examples skips that flag entirely.

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

MariaDB

MariaDB ships its own answer: a healthcheck.sh script baked into the official image. The MariaDB Foundation explains why mysqladmin ping falls short: it "will return success during database initialization (mariadb-install-db), or during the /docker-entrypoint-initdb.d initialization." The shipped script checks two things that matter, a real TCP connection and whether InnoDB has finished any crash recovery.

services:
  db:
    image: mariadb:11
    environment:
      MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD}
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

MySQL

The Oracle MySQL image has no equivalent shipped script, and it inherits the same temporary-server behavior; a documented issue against the official image shows mysqladmin ping -h 127.0.0.1 catching the temporary server on MySQL 5.7. I'll say this plainly: there's no bulletproof MySQL healthcheck recipe I'd stake a production deploy on without testing it against your exact version. A TCP ping with a generous start_period is the practical option; if you can choose your engine, MariaDB's shipped script is cleaner.

services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\""]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 20s

Redis

This one catches people off guard because the failure is silent. With --requirepass set, running redis-cli ping without credentials prints "NOAUTH Authentication required" and still exits with code 0, so a healthcheck that only checks the exit code will call that healthy. This exact bug was reported and fixed in the Dify platform's issue tracker, so it's not a theoretical edge case. The fix is to check the reply text, not just whether the command ran:

services:
  cache:
    image: redis:7
    command: redis-server --requirepass ${REDIS_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" --no-auth-warning ping | grep -q PONG"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 5s

Worth knowing: that password is visible in plain text in docker inspect, since it's part of the stored healthcheck command. That's an accepted tradeoff for local and staging, less so for anything internet-facing.

HTTP app

For a plain web service, curl -f (fail on HTTP errors) or wget --spider (check without downloading the body) against a local endpoint works, provided the binary is actually in the image. Minimal base images like alpine or distroless often ship neither, so a healthcheck that assumes curl exists can fail for a reason unrelated to your app's health. To confirm the service also responds correctly from outside the container network, httpcheck.tools checks the live response headers and status code from an external vantage point.

services:
  web:
    build: ./web
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 10s

Full working example: app + Postgres + Redis

Putting the pieces together, here's a stack where the app waits on both dependencies being ready, and gets restarted automatically if either one changes:

services:
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
        restart: true
      cache:
        condition: service_healthy
        restart: true
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      REDIS_URL: redis://:${REDIS_PASSWORD}@cache:6379

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  cache:
    image: redis:7
    command: redis-server --requirepass ${REDIS_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_PASSWORD\" --no-auth-warning ping | grep -q PONG"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 5s

How to verify it worked: run docker compose up and watch docker compose ps in another terminal. You should see db and cache sit in a "starting" health state briefly, then flip to "healthy" before app even starts. If app starts at the same moment as db, check for a typo in the condition key or a missing healthcheck: block on the target service.

If you'd rather not hand-write this file, the Compose generator wires this pattern automatically: it guesses a sensible healthcheck command per image and adds condition: service_healthy to any service that depends on one.

Why your app can still fail to connect after "healthy"

Here's the part that surprises people who did everything right: a healthy status is a snapshot, not a guarantee. It tells you the last probe succeeded, not that the database will still be reachable a second from now. Databases restart mid-deploy, and connection pools get exhausted right as your app's first query lands.

Compose itself also has a hard limit on patience. Once the Engine reports a container as unhealthy (after using up its retries), Compose stops waiting and fails the whole dependency chain. A Compose maintainer described this on the project's issue tracker: Compose "waits for engine to report health status... After healthcheck fails for configured delay, and engine reports unhealthy compose will consider service failure and stop."

Two practical takeaways follow. First, your app should still retry its own database connection on startup with backoff, even with a perfect healthcheck in place; the healthcheck reduces the odds of a race, it doesn't eliminate every timing issue. Second, for CI pipelines and deploy scripts that need a hard stop-and-wait, the Compose CLI reference documents a --wait flag on the up subcommand that blocks until every service with a healthcheck reports healthy (or hits --wait-timeout). That's a cleaner signal than polling logs for a "ready" string.

Troubleshooting by error message

A few error strings account for most of the confusion here.

"dependency failed to start: container db is unhealthy" means the healthcheck ran, exhausted its retries, and Compose gave up. Run docker inspect --format '{{json .State.Health}}' db to see the actual probe output; the Engine stores the last few checks' stdout and stderr for exactly this reason. Often the cause is a missing binary: curl on an image that only has wget, or mysqladmin on a MariaDB image that expects healthcheck.sh.

A container stuck at "(health: starting)" indefinitely usually means start_period is too short for a genuinely slow boot, or the probe command is wrong and never returns 0. Check the math: if a database takes 25 seconds to become reachable and your start_period is 10 seconds, you'll burn through retries before it has a fair shot.

"condition is not supported" or "must be a list" comes from an old compose file using the version: "3" schema, which dropped the condition form for a few years before it returned in the unified Compose Specification. Deleting the version: key entirely (it's obsolete in current Compose) usually resolves it. If you're not sure what else in an inherited file is stale, the Compose validator flags outdated syntax and unpinned tags alongside this kind of issue.

Frequently Asked Questions

Does depends_on wait for the service to be ready?

No, not by itself. The short-form depends_on (a plain list of service names) only controls the order containers start and stop in; the Docker documentation states that Compose "does not wait until a container is 'ready', only until it's running." To wait for readiness, you need the long-form syntax with condition: service_healthy, which requires the target service to define a healthcheck.

How do I write a healthcheck for Postgres or Redis?

For Postgres, use pg_isready with the -h 127.0.0.1 flag to force a TCP check rather than the Unix socket, which can report success while the init-only temporary server is running. For Redis with a password set, don't rely on the exit code of redis-cli ping alone, since it returns 0 even when authentication fails; pipe the output through grep -q PONG so the check verifies a successful reply. Both recipes are shown in full above.

What does condition: service_healthy do?

It's one of three options under the long-form depends_on syntax. It tells Compose to hold off starting the dependent service until that dependency's healthcheck reports healthy, rather than just waiting for the container process to start. The other two are service_started (equivalent to the short syntax) and service_completed_successfully, which waits for a one-shot container like a migration job to exit with code 0.

Why does my app still fail to connect on first start?

A few possibilities, in order of likelihood: the healthcheck is testing the wrong thing (a bare pg_isready or redis-cli ping that reports success prematurely), the start_period is too short for how long the database takes to boot on your hardware, or the app hit a brief connection failure unrelated to the healthcheck, since "healthy" is a point-in-time snapshot. Keep a short retry-with-backoff loop in your app's own connection code as a safety net; it's the difference between a transient blip and a crashed container.