5 min read
Docker Compose healthchecks
A container starting is not the same as a service being ready. This distinction is easy to overlook, and it causes real problems.
When you run docker compose up, Docker starts containers in dependency order. MySQL gets started before your Laravel app. But "started" just means the container process launched. MySQL still needs several seconds to initialise its data directory, run recovery checks, and begin accepting connections. Your app starts in that gap, tries to connect, and crashes.
I hit this exact issue on a Laravel project. The queue worker and scheduler would boot, attempt a database connection before MySQL was ready, and fail on startup. The fix is healthchecks.
What a healthcheck does
A healthcheck is a command Docker runs inside a container on a regular interval. If the command exits with 0, the container is healthy. If it consistently fails, Docker marks it as unhealthy.
You define a healthcheck on the service being monitored, not on the service that depends on it.
mysql: image: mysql:8.0 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 start_period: 30s
The fields:
test— the command to run. UseCMDto call the binary directly, orCMD-SHELLto run it via a shell.interval— how often Docker runs the check.10smeans every 10 seconds.timeout— how long Docker waits for the check to complete before treating it as a failure.retries— how many consecutive failures before marking the container unhealthy.start_period— a grace period after the container starts. Failures during this window don't count toward the retry limit. Useful for slow-starting services.
The three depends_on conditions
By default, depends_on only waits for the container to start, not for it to be healthy. There are three conditions you can use:
service_started— the default. Waits for the container to exist and be running.service_healthy— waits until the container's healthcheck reports healthy. This is what you want for databases, caches, and search services.service_completed_successfully— waits for the container to exit with code0. Useful for one-off migration or seed containers.
The bare list syntax (depends_on: - mysql) is equivalent to condition: service_started. It gives you no guarantee that MySQL is ready.
Healthcheck examples
MySQL:
mysql: image: mysql:8.0 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 start_period: 30s
mysqladmin ping connects to MySQL and checks if it responds. The start_period of 30 seconds gives MySQL time to initialise before failures start counting.
Redis:
redis: image: redis:alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 3
redis-cli ping returns PONG when Redis is up. Redis starts quickly, so a shorter interval and no start_period is usually fine.
Postgres:
postgres: image: postgres:16 healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 start_period: 20s
pg_isready is built into the Postgres image. Use CMD-SHELL here so the shell can expand the environment variables.
Wiring up depends_on
Once a service has a healthcheck, you can use condition: service_healthy in any service that depends on it.
mysql: image: mysql:8.0 healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 redis: image: redis:alpine healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 3 queue: build: . depends_on: mysql: condition: service_healthy redis: condition: service_healthy
Docker will hold the queue container until both MySQL and Redis pass their healthchecks. No more race conditions.
Apply the same pattern to your web container and scheduler:
web: build: . depends_on: mysql: condition: service_healthy scheduler: build: . depends_on: mysql: condition: service_healthy redis: condition: service_healthy
Checking healthcheck status
Once your stack is running, you can see the health status of any container with docker ps. Look for the STATUS column, which shows something like Up 2 minutes (healthy) or Up 30 seconds (health: starting).
docker ps
For more detail, docker inspect returns the full healthcheck history including the output of the last few checks:
docker inspect <container_name> | jq '.[0].State.Health'
This outputs the current status, the number of failing streak checks, and a log of recent check results with their exit codes and output. It is the fastest way to diagnose a container that is stuck in an unhealthy state.
Wrapping up
The depends_on shorthand is fine for containers that are ready the moment they start. For anything that needs initialisation time, skip the shortlist syntax and use condition: service_healthy instead. Define a healthcheck on each dependency, set a sensible start_period for slow starters, and let Docker handle the sequencing. It is a small change that removes an entire class of startup failures.