Your docker-compose.yml has depends_on wired up with condition: service_healthy. You run docker compose up, and the app container dies immediately trying to reach Postgres: connection refused. You shrug, run docker compose up again, and it works. It keeps working on every run after that. This isn't a fluke you can retry your way past forever — it's a specific, reproducible race condition tied to how Postgres (and MySQL) behave the very first time they boot against an empty data directory.
If you've only ever seen this once and then "fixed" it by rerunning the command, you haven't actually fixed anything. You've just hidden the bug behind a volume that now has data in it. The next time someone runs docker compose down -v, or the next time CI spins up a totally fresh environment, it comes back.
The Symptom: It Only Fails Once
The pattern is almost always the same. A fresh clone of the repo, a fresh docker compose up, and the dependent service — usually a PHP, Node, or Python app trying to run migrations or connect to the database — throws a connection error even though the database container shows as healthy in docker compose ps. Run the exact same command again without touching volumes, and it's fine.
That "works on the second try" detail is the whole clue. It means the problem isn't your healthcheck syntax, and it isn't a misconfigured depends_on block. If those were wrong, it would fail every single time, not just once.
Confirming This Is a First-Boot Race, Not a Broken Config
Before chasing the Postgres-specific cause, rule out the obvious stuff:
- Check that your healthcheck actually has a
testdefined and reasonableinterval/timeout/retriesvalues — a healthcheck that never runs will never report healthy, and Compose will just hang or time out, which looks different from this bug. - Confirm you're using
condition: service_healthyand not justdepends_onwith a plain service name, since plaindepends_ononly waits for the container to start, not for it to be healthy. - Reproduce deliberately with
docker compose down -v && docker compose up. If it fails on a fresh volume and then succeeds every time after, you've confirmed it's first-boot specific.
It also helps to pull timestamps out of the database container's logs with docker compose logs database and compare them against when the healthcheck flips to healthy and when the dependent container actually attempted its connection. You're looking for a very narrow window where things overlap in a way they shouldn't.
Root Cause: Postgres Boots Twice on a Fresh Volume
Here's the mechanism, and it's documented behavior in the official Postgres image's entrypoint script, not a bug in Docker or Compose.
When the data directory is empty, the entrypoint runs initdb to create the cluster, then starts a temporary Postgres instance specifically to execute anything in /docker-entrypoint-initdb.d/ — your schema files, seed scripts, extension setup, and so on. That temporary instance is typically reachable only through a local Unix socket, not the mapped TCP port, precisely so outside clients can't touch it mid-initialization. Once the init scripts finish, that temporary instance shuts down, and the real, final Postgres process starts and begins listening properly.
On every run after the first, none of this happens. The data directory already exists, initdb is skipped entirely, and Postgres starts exactly once. That's the entire reason the bug disappears after the first run — the two-phase startup sequence that causes it simply doesn't execute again.
MySQL's official image does something very similar during its bootstrap phase, which is why the same race shows up with MySQL healthchecks that connect over the local socket.
The danger is that Docker's healthcheck mechanism runs its test command inside the container's own namespace. If that test connects via the default local socket path instead of forcing a TCP connection, it can succeed against the temporary init instance. Docker samples health status on each interval tick — if a tick happens to land during that narrow window where the temp instance is up and accepting local connections, the container gets marked healthy right then. Compose sees healthy and immediately releases your dependent service, even though seconds later the temp instance shuts down and the real server is mid-restart.
Why service_healthy Doesn't Save You Here
This is the part people get wrong. condition: service_healthy does exactly what it says — it waits for the first transition to healthy and then moves on. It does not wait for health status to stabilize, and it has no concept of "this container is about to restart internally." Once Compose sees one healthy tick, it's done waiting, regardless of what happens to the process a moment later.
So even on current versions of Compose, this specific race can still occur. It's not a Compose bug. It's a timing mismatch between how Postgres boots and how healthchecks sample state.
The Fix: Healthchecks That Can't Be Fooled by the Temp Instance
The most reliable fix is forcing your healthcheck to test the real network path instead of whatever's easiest for the local socket to answer:
services:
database:
image: postgres:16
environment:
POSTGRES_PASSWORD: password
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 10
start_period: 15s
volumes:
- db_data:/var/lib/postgresql/data
app:
build: .
depends_on:
database:
condition: service_healthy
volumes:
db_data:
Forcing -h 127.0.0.1 pushes the check through the actual TCP listener rather than whatever socket the temporary init instance might be exposing locally. start_period gives the container a grace window where failures don't immediately count against the retry budget, which matters because on a fresh volume, initdb plus your init scripts can genuinely take several seconds longer than a warm start.
Defense in Depth: Don't Trust depends_on Alone
Even with a corrected healthcheck, don't make your application's resilience depend entirely on Compose getting the timing right. Add an actual connection retry loop at the application's entrypoint — something that attempts a real query, backs off, and retries for a bounded number of attempts before giving up. This catches any remaining edge case regardless of which orchestrator or Compose version you're running, and it's the same mindset that matters once you're pooling connections at scale, which is worth reading about separately if you're running Postgres behind something like pgbouncer for connection pooling.
Why This Bites Hardest in CI
On your laptop, this is a "happens once, then never again" annoyance because your volume sticks around between runs. In CI, every pipeline execution is a fresh environment by design — fresh containers, fresh volumes, every single time. That means a bug that looks like a rare fluke locally becomes a guaranteed, every-run failure in your pipeline. If you're structuring a CI/CD pipeline that spins up Postgres via Compose for integration tests, this is almost certainly why your tests are flaky against a database that Compose itself reports as healthy.
Verifying the Fix
Don't just rerun once and call it done — that's exactly the trap that hid this bug in the first place. Loop it:
for i in $(seq 1 10); do
docker compose down -v
docker compose up -d --wait || { echo "Failed on run $i"; docker compose logs; break; }
done
--wait blocks until your services are running and healthy, and returns an error if they aren't, so the loop stops at the first failure and prints the logs. If it completes all ten passes, including the very first one against a brand-new volume, you've actually closed the race condition rather than just gotten lucky.