depends_on Won't Save You: Docker Compose Healthcheck Ordering
depends_on doesn't wait for your database to be ready — it waits for the container to start. That gap has burned me more than once.
Every few months I onboard a new project or spin up a client's existing stack, and within ten minutes I've hit the same silent footgun: the app container starts, tries to connect to Postgres, fails, and either crashes or — worse — silently skips migrations and limps along in a broken state. The culprit is almost always a docker-compose.yml that uses depends_on without a healthcheck condition. This is such a common mistake that I'm writing it down once so I can just link to it.
What depends_on Actually Does
depends_on controls start order, not readiness. When you write this:
services:
app:
depends_on:
- db
Compose will start db before app. That's it. It does not wait for Postgres to accept connections. It does not wait for your Redis instance to finish loading its AOF. It starts the container and immediately moves on. If your database takes four seconds to initialize — and a fresh Postgres container absolutely does — your app process is already trying to connect before the socket is even listening.
I've seen this cause flapping integration test suites, busted CI pipelines, and confusing "works on my machine" reports where the dev had a warm, already-initialized volume and the CI runner had a cold one. Same compose file, different behavior depending on disk state. Maddening.
The Fix: condition: service_healthy
Docker Compose has had a solution for this since v2.1 of the file format, and it's still underused. You combine a healthcheck block on the dependency with a condition: service_healthy in your depends_on. Here's a full working example I'd drop into a Laravel project:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
- db_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
app:
build: .
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
DB_HOST: db
DB_PORT: 5432
REDIS_HOST: redis
command: >
sh -c "php artisan migrate --force &&
php artisan serve --host=0.0.0.0 --port=8000"
ports:
- "8000:8000"
volumes:
db_data:
Now when you docker compose up, the app container won't start until both db and redis have passed their healthchecks. Postgres's pg_isready is particularly good here — it checks that the server is actually accepting connections for the specified user and database, not just that the process is running.
The Gotchas That Bit Me
start_period is not optional for cold volumes. The first time Postgres initializes a data directory, it runs initdb, sets up authentication, and does a bunch of file I/O before it starts accepting connections. On a fresh volume this can take 8-15 seconds depending on the host. Without start_period, your healthcheck starts failing immediately, burns through your retries, and the container is marked unhealthy before Postgres has had a fair shot. I set start_period: 10s as a baseline and bump it if I'm on slow CI hardware.
The long-form depends_on syntax only works with Compose v2. If you're running an ancient Docker Desktop or a CI runner with a stale Docker install, the condition: key will be silently ignored or throw a parse error. Worth knowing. In practice, anything Docker Desktop 4.x or Docker Engine 20.10+ handles it fine, but I always check the runner's Docker version when CI starts acting weird.
Healthchecks don't carry over from base images. Some official images — the postgres image included — actually ship with a HEALTHCHECK instruction baked in. But it's often a no-op or too generic for your setup. Define your own in the compose file. Don't assume the base image's healthcheck matches your database name and user.
CMD vs CMD-SHELL matters. If you use CMD (the JSON array form), the binary runs directly. If you use CMD-SHELL, it goes through /bin/sh -c, which means you can use shell features like pipes and logical operators. For pg_isready I use CMD-SHELL because I sometimes want to add || exit 1 explicitly. For a simple redis-cli ping, CMD is fine.
service_started and service_completed_successfully. There are actually three conditions:
service_started— the default, same as the old baredepends_onservice_healthy— what you actually wantservice_completed_successfully— for one-shot init containers (migrations, seed scripts, etc.)
That third one is useful. I've used it to run a db-migrate service that just runs php artisan migrate --force and exits, and then have the actual app service depend on it with service_completed_successfully. Keeps the app container's entrypoint clean.
db-migrate:
build: .
depends_on:
db:
condition: service_healthy
command: php artisan migrate --force
restart: "no"
app:
build: .
depends_on:
db-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
Clean separation of concerns. The migration either succeeds or the app never starts — which is exactly the behavior you want in a dev environment.
When I'd Reach For This
Every time. There is no scenario where I want a race condition on startup. Whether it's a local dev stack, a CI environment, or a compose-based staging setup, healthcheck-gated ordering costs almost nothing to set up and eliminates an entire class of intermittent failures.
That said, if you're running production workloads on Docker Compose instead of something like ECS or Kubernetes, you have bigger architectural conversations to have. Compose is excellent for local dev, CI, and small-scale single-host deployments. I run several client projects on managed Docker hosts with Compose — a small e-commerce operation, an internal tooling app for a print management company — and it works fine. But if you're horizontally scaling, Compose's startup ordering is moot anyway because you're deploying containers independently.
For those production cases, your app code needs to handle connection retries gracefully regardless of what the orchestrator promises. Healthchecks in Compose don't teach your app to be resilient — they just make local dev less annoying.
When I Wouldn't Bother
If someone hands me a Compose file for a quick one-off task — spinning up a local Kafka cluster to test a message format, running a containerized CLI tool — I'm not going to invest five minutes adding healthchecks. The depends_on footgun is a real problem for shared, repeatedly-started environments. For a throwaway container you start once and trash, the race condition almost never manifests because you're not running migrations or doing startup logic that cares about timing.
Also, if your app has robust retry logic with exponential backoff on database connections, the race is less catastrophic. It's still sloppy to rely on that in your local compose stack, but it won't blow up. Laravel's database connection handling will retry a few times before dying. I'd still add the healthcheck, but I understand why some people don't bother when the app recovers anyway.
The Bottom Line
depends_on without condition: service_healthy is a lie you're telling yourself about startup ordering. It takes ten lines of YAML to do it right, and it eliminates a whole category of "works on my machine" nonsense. I've shipped this pattern into every new project I start, and I backfill it whenever I inherit a compose file that's missing it — which is almost every time.
Need help shipping something like this? Get in touch.