I lost probably forty minutes on this one the first time, and I still see it happen to other people. You spin up docker-compose up with a backend and a Postgres database. The backend has depends_on: [db]. Everything starts, the backend throws ECONNREFUSED two seconds in, the Postgres container is sitting there alive and well, and you start googling the error convinced it's a networking issue between services. It isn't. depends_on on its own did exactly what it promises: it waited for the db container to exist and be running. It never promised that Postgres inside it would be accepting connections.
I wrote about this same misunderstanding recently from another angle — the gap between what a healthcheck tells Docker and what it tells an orchestrator like Kubernetes. This post goes one level deeper: into the docker-compose.yml that anyone edits daily, no cluster, no readiness probes involved.
Docker Compose healthcheck: what it solves and what it doesn't
My thesis is simple: depends_on without condition is an illusion of order. It gives you container startup sequencing, not service availability sequencing. Only with condition: service_healthy do you get something verifiable — Compose won't start the second service until the first one's healthcheck reports healthy.
The source is the official Compose specification. It makes clear that healthcheck defines a command Docker runs periodically inside the container, and that depends_on accepts an object with condition instead of just a list of service names. The valid conditions are service_started, service_healthy, and service_completed_successfully. Without an explicit condition, the default is service_started — which is exactly the behavior that breaks expectations: container up, not necessarily the process inside responding.
Where people get it wrong
The common recipe I see in repos and tutorials — and, if I'm honest, the one I copy-pasted myself before it bit me — is this:
services:
db:
image: postgres:16
api:
build: .
depends_on:
- db
It compiles, it starts, it "works" in the demo because Postgres usually boots fast on a laptop with an SSD. The hidden cost shows up in CI, on a slower machine, or when someone adds an entrypoint.sh to the Postgres image that runs migrations before accepting connections. That's when the margin that "worked by luck" disappears and api fails its first connection. It's not dramatic, it's just annoying: a flaky pipeline that passes eight times out of ten and makes you doubt your own code before you doubt the compose file.
The counter-example that fixes this:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
api:
build: .
depends_on:
db:
condition: service_healthy
Now api doesn't start until pg_isready returns success multiple times per interval and retries. That's the difference between "the container exists" and "the container is ready," and it's exactly the same concept as the readiness probe Kubernetes formalizes with readinessProbe — but solved here at the local Compose level, no cluster needed.
flowchart LR
A[db starts] --> B{db healthcheck}
B -->|starting/unhealthy| B
B -->|healthy| C[api starts]
C --> D{api healthcheck}
D -->|healthy| E[stack ready]
Decision matrix: when to condition and when not to
Not every service needs this rigidity, and slapping healthcheck on everything is its own kind of cargo cult. Here's the check I actually run before adding one:
| Situation | What to do | Why |
|---|---|---|
| Stateful service (DB, cache, broker) that another service connects to on boot |
healthcheck + condition: service_healthy
|
Early connection failure is predictable and cheap to avoid |
| Stateless service that only exposes HTTP and tolerates client-side retries | Simple depends_on or none |
The cost of waiting can be higher than the cost of retrying in the app |
| One-shot migration job that runs and terminates | condition: service_completed_successfully |
What matters isn't "healthy," it's that it finished successfully |
| CI environment with shared resources and slow startups |
healthcheck with generous retries
|
A short timeout in CI produces false negatives you won't see locally |
| Microservice that already handles reconnection with backoff in its own code | Simple depends_on, let the app retry |
Duplicating wait logic in Compose and in the code is redundant |
What you CAN'T conclude from this
The Compose documentation doesn't tell you how long a healthcheck with specific interval, timeout, and retries values takes to converge in real production, because that depends on the image, the host, and the load — there's no universal number that fits every case. It also doesn't solve the problem of "service healthy but still can't handle real traffic" under load, which is different from "started up fine cold." And depends_on with condition, while it orders startup, isn't a continuous retry mechanism throughout the container's lifetime: if db goes down after api already started healthy, Compose doesn't go back to blocking anything. For that you need reconnection logic in the app's code, not in the docker-compose.yml.
Nor is it a substitute for Kubernetes's readinessProbe and livenessProbe if the final destination is a cluster: Compose solves the problem in local development or a staging docker-compose up, but the production orchestration guarantee lives at another level, with other tools and other restart semantics. What I don't buy is treating a green docker-compose up as proof that the same stack will behave in production — it proves ordering, nothing about load.
FAQ
Is depends_on without condition good for anything?
It's good for startup order and for making Compose stop containers in reverse order when you bring the stack down. It doesn't guarantee that the dependent service is ready to receive traffic.
What happens if the healthcheck never reaches healthy?
The service depending on it with condition: service_healthy never starts, and Compose reports it as a dependency failure. That's preferable to a silent startup that fails at runtime.
Does service_healthy work with any image?
Only if the image defines a healthcheck — either baked into the Dockerfile or declared in the docker-compose.yml. Without a defined healthcheck, Compose has no way to evaluate the condition.
Does this replace Kubernetes readiness probes?
No. They're analogous concepts but they live at different layers. Compose's healthcheck is for the world of local or simple self-hosted docker-compose up; readinessProbe is the equivalent piece when the destination is a Kubernetes cluster.
How many retries or what interval should I set?
There's no number that works for every case: it depends on the image and the environment. The sensible move is to start with conservative values, measure in your own CI pipeline or staging environment, and adjust based on what you observe there — not copy a value from a generic example.
Is condition: service_completed_successfully the same as service_healthy?
No. service_completed_successfully waits for the container to exit with a zero exit code, useful for one-shot migration jobs. service_healthy waits for a healthcheck that keeps running while the container is alive.
Bottom line
If a project's docker-compose.yml has depends_on as a plain list against a stateful service, there's a design flaw waiting for the worst possible moment to show up — probably in CI, with tighter resources than your dev laptop. The fix isn't complex: a well-defined healthcheck and condition: service_healthy. What I do ask is that you don't confuse this with a guarantee of continuous availability, nor with the equivalent of a production Kubernetes readiness probe. It's a guarantee of ordered startup in a local or simple staging environment, and that's how I treat it: useful, verifiable against the official source, and with limits worth knowing before you assume more than it delivers. The uncomfortable question worth asking your own repo today: does your compose file order containers, or does it order services actually being ready?
If the stack is already using TanStack Query against Server Actions, it's worth checking how cache invalidation is handled in that post about setQueryData. And if the next step is Next.js with layered caching, the difference between revalidatePath and revalidateTag is covered in this other analysis.
Original source: Docker Compose Spec — healthcheck
This article was originally published on juanchi.dev
Top comments (0)