Docker Compose healthchecks that work
How Docker Compose healthchecks are evaluated, why depends_on alone waits for nothing useful, and how to write readiness checks for Postgres and your app.
What a Docker Compose healthcheck actually does
A Docker Compose healthcheck is one command that Docker runs inside the container on a timer. Docker does not read your logs, watch your port, or inspect your process list. It runs the command, reads the exit code, and stores a single state on the container: starting, healthy, or unhealthy. Exit code 0 means healthy. Any other exit code means unhealthy, and exit code 2 is reserved by Docker, so never return it on purpose.
That is the whole mechanism. Almost every healthcheck problem is the same problem: the command you wrote answers a different question from the one you meant to ask. This guide assumes you already know how to write a compose file on a VPS, and picks up at the point where the stack starts in the wrong order.
services:
api:
image: ghcr.io/example/api:1.4.0
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30sThe test value takes two useful forms. A list starting with CMD runs the command directly, with no shell, so pipes and && and variable expansion do not work. A list starting with CMD-SHELL passes the rest as one string to /bin/sh -c inside the container, which is what you want whenever the check needs shell syntax. A plain string is treated as CMD-SHELL. A list of exactly ["NONE"] removes a healthcheck that the image baked in through its Dockerfile.
The check runs inside the container, so every binary it names must exist in that image. Verify that first, because a slim image with no curl produces a container that is permanently unhealthy for a reason that never appears in the application log. Test it by hand:
docker compose exec api curl --versionA missing binary answers with OCI runtime exec failed: exec: "curl": executable file not found in $PATH: unknown. Alpine based images usually ship BusyBox wget instead, so the check becomes ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"].
How interval, retries and start_period combine
Five settings control the timing. Their defaults come from Docker Engine, not from Compose.
interval: time between two checks once the container is past its start period. Default 30s.timeout: how long one run of the check may take before Docker kills it and counts that run as a failure. Default 30s.retries: how many consecutive failures are needed before the state flips tounhealthy. Default 3.start_period: a grace window after the container starts. Default 0s.start_interval: how often the check runs during the start period. Default 5s, and it needs Docker Engine 25.0 or newer.
The rule that matters: during the start period a failing check does not count toward retries, and the container stays in starting. The first time the check succeeds, the container becomes healthy and the start period ends immediately, even if most of its time is unused. If the start period runs out while the check is still failing, the normal countdown begins, and the container needs retries failures in a row before it is marked unhealthy.
So the worst case time from container start to unhealthy is start_period plus retries multiplied by interval plus timeout. With the values in the file above that is 30 plus 5 times 13, which is 95 seconds. Write that number down before you set a deploy timeout, because a rollout that gives up after 60 seconds will never see this container reach a final state.
The common mistake here is raising retries to cover a slow start. That works once and then hurts forever: a service that needed 8 retries to boot now tolerates 8 consecutive failures in production before anything notices. Use start_period instead, because it applies only before the first success.
Why depends_on on its own guarantees nothing
The short form of depends_on is the source of most of the confusion.
api:
depends_on:
- dbThis means one thing: start the db container before the api container. Compose waits for the container to be created and started. It does not wait for PostgreSQL to finish its first time initialisation, and it does not wait for port 5432 to accept a connection. Your app starts about a second later, connects to a port nothing is listening on yet, and exits. In the log you see Connection refused, or FATAL: the database system is starting up when the server is up but still recovering.
The long form is what people actually want:
api:
depends_on:
db:
condition: service_healthy
restart: true
migrate:
condition: service_completed_successfullycondition has three values. service_started is the same as the short form. service_healthy holds the dependent service back until the dependency reports healthy, which is only meaningful when that dependency defines a healthcheck, either in the compose file or in its image. service_completed_successfully waits for a one shot container, such as a database migration, to exit with status 0.
Two extra fields sit next to condition. restart: true tells Compose to restart this service after it updates the dependency service. required: false downgrades a missing dependency from an error to a warning.
Now the limit that catches people. These conditions are evaluated when the stack comes up. They are start ordering, not a supervision rule. If the database restarts at three in the morning, nothing re-evaluates service_healthy and nothing restarts your app to satisfy it again. Your application code still has to reconnect on its own. docker compose up --no-deps api skips the whole mechanism by design, and so does starting a container directly with docker start.
Write a check that tests readiness, not that a process exists
A check like pgrep nginx proves that a process table entry exists. It proves nothing about whether the service can answer a request. A web application can hold its listening socket open long after its database pool has died, and the process check stays green through the whole outage.
Ask the container to do the work it exists to do:
- For an HTTP service, request a real endpoint.
curl -fsSexits non zero on any status of 400 or above because of-f, so a 500 from a broken app is a failed check. - For PostgreSQL, use
pg_isready, which exits 0 when the server is accepting connections, 1 when it is rejecting them, 2 when it does not respond at all, and 3 when the parameters you passed were wrong. - For Redis, use
redis-cli ping, which printsPONGand exits 0. - For MariaDB, the official image ships a
healthcheck.shscript, andhealthcheck.sh --connect --innodb_initializedis the form its maintainers document.
pg_isready has one trap worth knowing. On its very first start with an empty data directory, the official postgres image runs its initialisation against a temporary server that listens only on the Unix socket. pg_isready with no host argument uses that socket, so it can answer "accepting connections" while TCP port 5432 is still closed to your application. Point the check at TCP explicitly and the problem goes away, because the temporary server does not answer there.
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30sThe doubled dollar signs are not a typo. Compose expands $VAR itself while reading the file, which would bake a value from your host environment into the check. $$ escapes it down to a single $, so the shell inside the container expands it against the container's own environment.
A postgres and app stack that starts in the right order
services:
db:
image: postgres:17.5
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
POSTGRES_DB: appdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
start_period: 30s
restart: unless-stopped
api:
image: ghcr.io/example/api:1.4.0
environment:
DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/appdb
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
ports:
- "127.0.0.1:8080:8080"
restart: unless-stopped
volumes:
pgdata:Bring it up and watch the states change:
docker compose up -d
docker compose psThe STATUS column carries the health state in brackets. A healthy pair reads Up 41 seconds (healthy) on both rows. While the database is still initialising, db reads Up 4 seconds (health: starting) and api is missing from the list, because Compose has not created it yet.
To see why a check passed or failed, read the health log:
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q db)"Docker keeps the last few results, each with a start time, an end time, an ExitCode and the command's Output. The stored output is truncated, so a check that prints a large page body gives you a useless log entry. Keep checks quiet.
What Docker does when a container turns unhealthy
Nothing. This is the answer that surprises people most.
Docker Engine on a single host does not restart an unhealthy container. The restart: unless-stopped policy reacts to the main process exiting, and an unhealthy container has not exited. It can sit at unhealthy for a week while Compose leaves it alone. Swarm mode replaces unhealthy tasks, but a plain Compose stack on one server does not.
That leaves two honest options. Make the process exit when it knows it is broken, so the restart policy has something to act on. Or watch the state from outside and alert on it. Pointing an Uptime Kuma monitor at the same endpoint your healthcheck calls means a broken dependency shows up in both places, and you hear about it from the monitor rather than from a user. If traffic reaches the app through a Traefik reverse proxy, remember that the proxy's own view of a backend is separate from the Docker health state, so one does not cover the other.
Debugging a check that never turns healthy
Run the exact command yourself, in the same container, and look at the exit code:
docker compose exec api curl -fsS http://localhost:8080/healthz; echo "exit=$?"exit=0 here while the container still reports unhealthy means your compose test differs from what you just typed, usually because CMD was used where shell syntax was needed.
Two mistakes account for most of the rest. The first is the wrong port. The healthcheck runs inside the container, so it must use the container port, never the published host port. With ports: - "8080:3000" the application listens on 3000, and a check against http://localhost:8080 fails forever while the site works fine in a browser. The second is the wrong host. Inside the check, localhost is that same container, which is right for checking itself and wrong for checking a neighbour, where you need the service name, for example db.
One last case deserves a name: the healthcheck passes while users see errors. That happens when the endpoint returns a static 200 without touching anything real. A readiness endpoint that never queries the database cannot tell you the database is gone. Make it run one cheap real query.
FAQ
Why does my app still fail to connect when depends_on says the database is healthy?
Because condition: service_healthy is evaluated once, when the stack starts. It does not supervise anything afterwards. If the database container restarts later, Compose does not restart your application to satisfy the condition again, so your application code needs its own reconnect and retry logic. The condition also does nothing when you start a single container with docker start or with docker compose up --no-deps.
Do I need a healthcheck if the image already defines one?
Usually not, and overriding it is often a step backwards, because the image maintainer knows what readiness means for that software. Add your own only when the image check is wrong for your setup, for example when it probes a port you moved. To turn an image healthcheck off, set test: ["NONE"] or disable: true on the service.
Should the healthcheck use curl or wget?
Use whichever one already exists in the image, and confirm it with docker compose exec <service> curl --version before you rely on it. Many Debian based images have neither. Alpine based images have BusyBox wget. Do not add a package to an image only to run a healthcheck when the software ships its own client, such as pg_isready or redis-cli.
Does an unhealthy container get restarted automatically?
Not by Docker Engine on a single host. Restart policies react to the process exiting, not to the health state, so an unhealthy container stays up and stays broken until something else acts on it. Either make the process exit when it detects the failure, or run an external monitor that alerts on the state.
How long should start_period be?
Long enough for the slowest legitimate first start you have measured, plus a margin. Time it with docker compose up against an empty volume, since the first start of a database is far slower than every start after it. A start period that is too long only delays the first unhealthy verdict. Retries that are too high weaken the check for the whole life of the container, which is the worse failure.