← Knowledge hub
Tutorialby Uptime Room· September 24, 2026· 6 min read

Docker Health Check: Syntax, Compose, and Gotchas

A docker health check tells the daemon whether your container can still serve. Here is the syntax, the Compose ordering trick, and what it cannot see.

A docker health check is a command the daemon runs inside your container on a schedule to decide whether the process is still doing its job. A container can be running and completely stuck, and the plain process state cannot tell the difference. The check exists to catch exactly that case, and it reports one of three statuses: starting, healthy or unhealthy.

When is a docker health check worth adding?

Not always, and it is a fair question to ask. On a single container with no dependants and no orchestrator, the status is decoration: nothing reads it and nothing acts on it. That is why plenty of self-hosters run dozens of containers without one and lose nothing.

It earns its place in three situations. When another service waits on this one to be ready. When an orchestrator can replace a task that reports unhealthy. And when the failure you care about is a stuck process rather than a crashed one. A deadlocked worker keeps its PID and looks perfectly fine to the daemon.

Writing the Dockerfile HEALTHCHECK instruction

The instruction takes a command plus four timing options. Exit code 0 means healthy, exit code 1 means unhealthy, and exit code 2 is reserved, so never return it deliberately.

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
  CMD curl -fsS http://localhost:8080/health || exit 1
OptionDefaultWhat it controls
--interval30sGap between checks once running
--timeout30sHow long one check may take before it counts as a failure
--start-period0sGrace window where failures do not count against retries
--retries3Consecutive failures before the status flips to unhealthy

Work out what those numbers mean together. With the values above, a broken container stays healthy for up to 30 seconds. It then needs three more failed checks, so the status flips roughly 120 seconds after the fact. Shorten the interval before you touch the retry count. Dropping to one retry makes a single blip look like an outage.

--start-period is the option people leave out and then regret. A JVM or a Rails app that takes 40 seconds to boot will burn through its retries during startup and be marked unhealthy before it ever served a request. The full syntax lives in the Dockerfile reference.

Use HEALTHCHECK NONE to switch off a check inherited from a base image you do not control.

Doing it in Compose

The Compose equivalent is a block on the service, and it unlocks the genuinely useful feature: ordering startup on health rather than on process start.

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 30s

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 20s

depends_on with condition: service_healthy holds the api container until the database answers, which removes a whole category of connection-refused crashes on a cold compose up. Note the two test forms. CMD runs the command directly with no shell, and CMD-SHELL wraps it in /bin/sh -c, which is what you need for pipes, redirects or ||. The Compose file reference documents both.

Checking the result

The status shows up in docker ps output. The detail lives in the container's state.

docker inspect --format '{{ .State.Health.Status }}' api
docker inspect --format '{{ json .State.Health }}' api | jq

The second command returns the last five probe results with their exit codes and captured output, using the format strings documented in the docker inspect reference. That is where you find out that curl exited 127 because it is not installed. Failing checks are almost always one of three things. The binary is missing, the port is bound to an external interface rather than localhost, or the endpoint is slower than --timeout.

What about containers with no shell?

Distroless and scratch images have no curl, no wget and often no /bin/sh, so the usual one-liner fails instantly with exit code 127. There are two clean answers.

Ship a tiny health binary into the image and call it directly with the exec form. In Go this is a ten-line program that does an HTTP GET and exits nonzero, and it costs a couple of megabytes. Several base images ship something similar already.

Alternatively, expose the health logic through the application binary itself, as a subcommand like myapp healthcheck. It reuses your existing HTTP client and config parsing and adds nothing to the image. We prefer this one, because it keeps the check and the service in the same build and the same version.

Common docker health check mistakes

Assuming an unhealthy container gets restarted. Plain Docker does not do this. The status flips to unhealthy and nothing else happens, because restart policies react to the process exiting, not to the health status. Swarm and Kubernetes act on it; docker run and docker compose up do not. If you want a restart, the process has to exit.

Pointing the check at a dependency. A check that queries the database marks your api container unhealthy whenever the database hiccups, which then holds up everything with a depends_on on it. Check that this process can serve, not that the whole system is well.

Making the endpoint expensive. A /health route that runs three queries and serializes a report is now running every 30 seconds in every replica forever. Keep it cheap, and put the deep version behind a different path you call manually.

Setting a timeout longer than the interval. Checks then overlap and the arithmetic stops making sense. Keep the timeout well under the interval.

What this does not tell you

The check runs inside the container, on the host, on your network. That scope is the whole point, and it is also the limit. A passing status means the process answers on localhost. It says nothing about whether a user can reach you.

Expired certificates, stale resolver records, a load balancer routing to a drained pool, an entire host off the network. None of it shows up here, because the probe never crosses the boundary where those failures live. An external check answers the other half of the question, and the two are complementary rather than competing.

curl -X POST https://uptimeroom.com/api/v1/monitors \
  -H "Authorization: Bearer urm_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "api public",
    "type": "api",
    "target": "https://api.example.com/health",
    "intervalSec": 60,
    "regions": ["london", "singapore"],
    "expectedStatus": [200],
    "alertAfterCycles": 2
  }'

Point it at the same endpoint your container checks and you get both views of one route. In our testing the two disagree more often than teams expect, usually on TLS or on a proxy rule nobody remembered. Run it from two of the six probe regions available as of September 2026 so one bad network path does not page anyone, a pattern described in cross-region quorum confirmation. Egress addresses are on the probe IPs page.

Treat the docker health check as what it is: a liveness signal for the daemon and for startup ordering, and a very good one. Set a realistic start_period, keep the endpoint cheap, and remember that nothing restarts on its own outside an orchestrator. For the Prometheus route to the same problem see the blackbox exporter guide, and for timing rather than liveness see response time monitoring. The outside view takes a few minutes to add on the free plan.

FAQ

What does healthcheck in Docker do?

It runs a command inside the container on a schedule and uses the exit code to set a status of starting, healthy or unhealthy. Exit 0 means healthy and exit 1 means unhealthy. The status appears in docker ps and in the container state, and orchestrators use it for startup ordering and for replacing failed tasks.

How do I check if a Docker container is healthy?

Run docker ps and read the status column, which shows the health state in brackets beside the uptime. For detail, use docker inspect with a format string against .State.Health, which returns the last few probe results including exit codes and captured output. That output is where you find a missing binary or a timeout.

Does Docker restart an unhealthy container?

Not on its own. Plain Docker marks the container unhealthy and takes no further action, because restart policies react to the process exiting rather than to the health status. Swarm and Kubernetes do act on health. With docker run or docker compose up, a restart only happens if your process actually exits.

How do I perform a health check in Docker Compose?

Add a healthcheck block to the service with a test array plus interval, timeout, retries and start_period. Use CMD for a direct exec and CMD-SHELL when you need a shell for pipes or redirects. Other services can then wait on it using depends_on with condition set to service_healthy.

How do you add a health check to a distroless container?

The usual curl one-liner fails because no shell or HTTP client exists in the image. Either ship a small purpose-built health binary and call it with the exec form, or add a healthcheck subcommand to your own application binary. The second reuses your existing config and client code and adds nothing to the image size.

Related reading