CoreMesh

Docker Compose Healthcheck Examples You Can Copy and Test

Docker Compose healthcheck syntax (test, interval, timeout, retries, start_period), depends_on service_healthy, status checks, and why curl probes fail.

Docker Compose Healthcheck Examples You Can Copy and Test

A Docker Compose healthcheck is a command Docker runs inside the container on a schedule. Exit code 0 means the container is healthy, exit code 1 means it isn't. You declare it under a service with the healthcheck key: test holds the command, and interval, timeout, retries, start_period and start_interval control the timing. Compose uses the same mechanism and the same defaults as the HEALTHCHECK instruction in a Dockerfile, and anything you set in the Compose file overrides what the image declared.

Here's the smallest useful version. The service builds from the current directory, and the probe asks the app for its root page from inside the container:

yaml
services:
  web:
    build: .
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

The one thing to internalize before anything else: the probe runs inside the container, with the container's binaries and the container's network view. If the image has no curl, this check fails forever. If the app listens on port 8000, http://localhost with no port hits nothing. Most "healthcheck not working" reports come down to one of those two.

Docker compose healthcheck parameters#

Every key under healthcheck maps to an option on the Dockerfile HEALTHCHECK instruction, so the defaults come from the Dockerfile reference.

Key What it does Default
test The command to run. A list starting with CMD, CMD-SHELL or NONE, or a plain string. none (no check unless the image defines one)
interval Time between probes. The first probe runs this long after the container starts, and each later one runs this long after the previous probe finishes. 30s
timeout How long a single probe may run. Past this, the probe counts as failed and its process is killed with SIGKILL. 30s
retries Consecutive failures needed before the container is marked unhealthy. 3
start_period Grace window after start. Failures inside it don't count toward retries. 0s
start_interval Spacing between probes during the start period. Needs Docker Engine 25.0 or later. 5s

interval, timeout, start_period and start_interval are durations, written like 10s or 1m30s. The Compose services reference lists start_interval as introduced in Docker Compose 2.20.2, so older Compose binaries won't accept it.

The test key accepts three shapes, and they aren't interchangeable:

yaml
# Exec form: no shell, arguments passed as-is
test: ["CMD", "curl", "-f", "http://localhost"]

# Shell form: runs through /bin/sh -c, so || and pipes work
test: ["CMD-SHELL", "curl -f http://localhost || exit 1"]

# Plain string: identical to CMD-SHELL
test: curl -f http://localhost || exit 1

With CMD, the arguments go straight to the executable. Nothing expands variables, and || is just another argument. If you need shell features, use CMD-SHELL or the string form. Both run through the container's default shell, which is /bin/sh on Linux.

How a docker compose healthcheck works#

A container with a healthcheck gets a health status on top of its normal running state. It begins as starting. The first time a probe passes, it becomes healthy. After retries consecutive failures, it becomes unhealthy. A pass at any point flips it back to healthy, whatever it was before.

Exit codes are the whole protocol:

Exit code Meaning
0 Healthy, ready for use
1 Unhealthy, not working correctly
2 Reserved, don't use it

The start period has a subtlety that bites people. Failures during start_period are ignored, which is what you want for an app that takes a minute to boot. But the moment one probe succeeds inside that window, Docker treats the container as started, and every failure after that counts toward retries even though the window hasn't closed. A service that briefly answers, then reinitializes, can go unhealthy inside its own grace period.

During the start period, probes run every start_interval instead of every interval. That lets you keep interval relaxed for steady state while still noticing within seconds that the app has come up, which matters when another service is waiting on service_healthy.

Whatever the probe writes to stdout or stderr is stored with the health status, trimmed to the first 4096 bytes, and Docker emits a health_status event whenever the status changes. Both are useful for debugging, covered below.

This script walks through the counting rules so you can watch the status flip. It spaces every probe by interval for simplicity; a real container would use start_interval inside the start period, but the counting logic is the same:

python
# Simulate how Docker turns probe results into a health status.
# Rules: status starts as "starting"; any pass -> "healthy";
# `retries` consecutive failures -> "unhealthy"; failures inside
# start_period are ignored until a probe has passed once.

def simulate(results, interval=30, retries=3, start_period=0):
    status = "starting"
    failures = 0
    started = False
    t = 0
    for ok in results:
        t += interval
        in_grace = (t <= start_period) and not started
        if ok:
            status = "healthy"
            failures = 0
            started = True
        elif in_grace:
            pass  # ignored during start_period
        else:
            failures += 1
            if failures >= retries:
                status = "unhealthy"
        print(f"t={t:>4}s  probe={'pass' if ok else 'FAIL'}  ->  {status}")
    return status

print("Defaults, app needs about 100s to boot:")
simulate([False, False, False, True], interval=30, retries=3, start_period=0)
print()
print("Same app with start_period of 90s:")
simulate([False, False, False, True], interval=30, retries=3, start_period=90)

Healthcheck examples for curl, wget and Postgres#

A curl check that only passes on success. Without -f, curl exits 0 on a 500 response because the HTTP exchange itself worked. The -f flag makes curl exit non-zero on HTTP error responses, as described in the curl manual. The trailing || exit 1 normalizes every failure to the exit code Docker documents as unhealthy:

yaml
healthcheck:
  test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 20s

When curl isn't in the image. Alpine-based images usually carry BusyBox wget but not curl. Either install curl in your Dockerfile or probe with what's already there:

yaml
healthcheck:
  test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8000/health || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3

If the image is a Python app, the interpreter itself is a fine probe and needs no extra packages. An unhandled exception exits Python with status 1, which is exactly the code you want:

yaml
healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
  interval: 30s
  timeout: 5s
  retries: 3

Postgres. The official Postgres image ships pg_isready, a client tool that exits 0 when the server accepts connections. Docker's own docs don't cover it, but it's the standard choice. Match the user and database to the environment you set:

yaml
services:
  db:
    image: postgres:18
    environment:
      POSTGRES_USER: example
      POSTGRES_DB: exampledb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U example -d exampledb || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

If you're building a web app and database pair from scratch, the Docker Compose tutorial walks through the full file this slots into.

Wait for a healthy dependency with depends_on#

The main reason to define a healthcheck in Compose, rather than only in the Dockerfile, is depends_on. Short-form depends_on only orders creation: the database container exists before the app container, but it may still be initializing when the app tries to connect. The long form adds a condition:

yaml
services:
  web:
    build: .
    depends_on:
      db:
        condition: service_healthy
        restart: true
      redis:
        condition: service_started
  redis:
    image: redis
  db:
    image: postgres:18
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5

The three conditions:

  • service_started is the same as the short syntax. Compose starts the dependency first and moves on.
  • service_healthy makes Compose wait until the dependency's healthcheck passes before creating the dependent service. The dependency needs a healthcheck, from the Compose file or baked into its image, or there's nothing to wait on.
  • service_completed_successfully waits for a one-shot dependency, such as a migration container, to run to successful completion.

Two extra flags matter. restart: true (Docker Compose 2.17.0 and later) tells Compose to restart the dependent service when it updates the dependency through a Compose operation; it doesn't cover runtime restarts after a crash. required: false (2.20.0 and later) downgrades a missing or unavailable dependency from an error to a warning.

Compose also removes services in dependency order, so web goes down before db.

This ordering is what makes a reverse proxy wait for its upstream. If you front an app with Nginx, the Nginx reverse proxy config example pairs well with a service_healthy dependency on the backend.

How to check healthcheck status#

docker compose ps lists each service's container with its state, and once a healthcheck is defined the health status shows alongside it. That's enough to see whether something is stuck in starting or has gone unhealthy.

For the why, run docker inspect on the container. The health section holds the current status, the failure streak, and a log of recent probe runs with their exit codes and whatever the command printed:

bash
docker inspect my-project-web-1

Look for the Health block in the output. If every entry in the log shows the same error, you have a configuration problem, not an app problem. An "executable not found" style message means the probe binary isn't in the image. A consistent connection refused means the URL or port is wrong for the container's own network view.

The fastest check of all is running the probe by hand in the same container:

bash
docker compose exec web curl -f http://localhost:8000/health
echo "exit code: $?"

If that fails, the healthcheck fails the same way. If it passes but the healthcheck still fails, look at timeout and at the difference between CMD and CMD-SHELL.

Docker also fires a health_status event on every transition, which you can watch with docker events while a stack comes up. And if docker inspect itself refuses with a permission error, that's a socket problem rather than a healthcheck one; the Docker permission denied fix covers it.

How long Docker takes to notice a dead service depends on three settings at once, so here's a quick calculator for the worst case, where every probe hangs until timeout:

javascript
// Parse Compose duration strings (e.g. "1m30s") and estimate the
// longest a broken container can sit before Docker marks it unhealthy.
const UNITS = { h: 3600, m: 60, s: 1, ms: 0.001 };

function parseDuration(str) {
  let total = 0;
  for (const [, num, unit] of str.matchAll(/(\d+(?:\.\d+)?)(ms|h|m|s)/g)) {
    total += parseFloat(num) * UNITS[unit];
  }
  return total;
}

function worstCaseToUnhealthy({ interval, timeout, retries }) {
  const i = parseDuration(interval);
  const t = parseDuration(timeout);
  // each failing probe can burn up to `timeout` before it is killed,
  // and the next one starts `interval` after the previous completes
  return retries * (i + t);
}

const configs = [
  { name: "Docker defaults", interval: "30s", timeout: "30s", retries: 3 },
  { name: "Compose docs example", interval: "1m30s", timeout: "10s", retries: 3 },
  { name: "Tight local dev", interval: "5s", timeout: "3s", retries: 5 },
];

for (const c of configs) {
  console.log(`${c.name}: up to ${worstCaseToUnhealthy(c)}s before unhealthy`);
}

Disable a docker compose healthcheck#

Some images ship a HEALTHCHECK you don't want, often because the probe expects a port you've changed or a tool the slimmed-down image lacks. Two equivalent ways to switch it off:

yaml
healthcheck:
  disable: true
yaml
healthcheck:
  test: ["NONE"]

One gotcha with extends: a service that extends another can't set disable: true unless the referenced service also sets it. Compose returns an error. Put the disable in the base definition, or override test instead.

Common mistakes when the healthcheck isn't working#

The probe binary isn't in the image. curl is the usual victim. Distroless, Alpine and many slim images don't have it. Check with docker compose exec web which curl before you blame anything else, then install it or switch the probe as shown above.

The command exits 0 when it shouldn't. A bare curl http://localhost returns 0 on a 503. Always make the probe fail loudly: -f for curl, and || exit 1 so every failure maps to the documented exit code 1. Never exit 2; Docker reserves it.

Shell syntax in the CMD form. ["CMD", "curl", "-f", "http://localhost", "||", "exit", "1"] hands || to curl as an argument. Use CMD-SHELL whenever you write ||, &&, pipes or variables.

Wrong host or port. The probe runs inside the container's network namespace. Use localhost and the port the process listens on, not the host-side port from ports: and not another service's name. An app published as 8080:80 is reached on port 80 from inside.

Start period too short. With the defaults, Docker starts probing 30s after launch and marks a slow-booting app unhealthy after three consecutive failures. Set start_period to cover the slowest realistic boot, and remember that a single success ends the grace window early.

Timeout too tight. A probe killed at timeout counts as a failure even if it would have succeeded a second later. Keep timeout comfortably above the probe's normal response time, and keep the probe itself cheap.

Expecting a restart. The Docker docs describe two effects of an unhealthy status: the status itself and the health_status event. Nothing in the healthcheck reference says the container is restarted. If you need that, something outside the healthcheck has to act on the status.

Expecting service_healthy without a healthcheck. If the dependency has no healthcheck anywhere, condition: service_healthy has no signal to wait for. Define one in Compose, even a simple one, before relying on the condition.

Compose too old for start_interval. The key is listed as introduced in Compose 2.20.2, and the Engine option needs Docker Engine 25.0 or later. On older setups, drop it and lean on a longer start_period.

Quick reference#

  • Define healthcheck under the service. test is the command; the timing keys default to interval: 30s, timeout: 30s, retries: 3, start_period: 0s, start_interval: 5s.
  • Use CMD-SHELL or a plain string when the command contains ||, pipes or variables. Use CMD for a plain exec.
  • End shell-form probes with || exit 1 so every failure returns exit code 1.
  • Probe localhost on the container's own port, with a binary the image actually has.
  • Set start_period longer than the slowest boot you expect.
  • Pair it with depends_on and condition: service_healthy so dependents wait for a real ready signal.
  • Check with docker compose ps, read the probe log with docker inspect, and reproduce by hand with docker compose exec.
  • Turn off an inherited check with disable: true or test: ["NONE"].

Quick answers

What does start_period do in a Docker Compose healthcheck?

It's a grace window after the container starts during which failed probes don't count toward retries. Probes run every start_interval instead of every interval during that window, and the first successful probe ends the grace period early, after which failures count normally.

How do I disable a healthcheck in Docker Compose?

Set healthcheck: disable: true on the service, or set test: ["NONE"]. Both switch off a HEALTHCHECK inherited from the image. If the service uses extends, the base service must also set disable: true or Compose returns an error.

Why is my Docker Compose healthcheck with curl not working?

Usually curl isn't installed in the image, or the probe targets the wrong port for the container's own network. Run the same curl command with docker compose exec to confirm, use -f so HTTP errors fail, and add || exit 1 in CMD-SHELL form so the exit code is 1.

Does Docker Compose restart an unhealthy container?

The Docker docs describe an unhealthy status and a health_status event, but nothing in the healthcheck reference says the container is restarted. The status mainly feeds depends_on with condition: service_healthy and your own monitoring.

How do I write a Docker Compose healthcheck for Postgres?

Use the pg_isready client that ships in the official Postgres image, for example test: ["CMD-SHELL", "pg_isready -U example -d exampledb || exit 1"] with a short interval and a start_period of around 30s. Then point dependent services at it with condition: service_healthy.

Did this solve it?

One tap. No account.

Saved you an hour? Send a tip.

USDC or ETH on Base, Ethereum, Arbitrum and more. Keeps the notes free and the examples running.

Base

Network

0xc783bc5c3fa14cEC3f09A22284bC91C5AF0d59e1
Open in wallet · 5 USDC

On a computer? Scan the code with your phone wallet, or copy the address.

Pick the network your wallet or exchange uses. USDC works on all six; ETH on Base, Ethereum, Arbitrum and Optimism. Don't send on Tron or Solana: this address doesn't exist there. Tips are optional and unlock nothing; every guide stays free.

Discussion

Start the discussion

Indra Wijaya

Builds and runs AI agents, bots and content pipelines, and writes down what works and what broke.

About · Email · RSS

Keep reading

New notes, no spam.

One post at a time, only when there's something worth reading. Follow in any feed reader.

Subscribe via RSS