Skip to main content
DevOps from zero · Lesson 23 of 25 · 14 min

Docker Healthcheck Tutorial: Health States and Restart Policies

Docker healthcheck tutorial: write one, read starting, healthy and unhealthy, gate a deploy with --wait, and see why restart policies ignore health.

By Matthews WongReviewed on

Key takeaway

A Docker healthcheck is a command Docker runs inside a container on a schedule: exit 0 means healthy, exit 1 means unhealthy. The status starts as starting, turns healthy on the first pass and unhealthy after a set number of failures in a row. Restart policies are separate: they react only when the process exits, so an unhealthy container keeps running.

You will be able to

  • Write a health check in a Dockerfile, a Compose file or a docker run command, and read its status and log with docker inspect.
  • Work out from interval, timeout, retries, start_period and start_interval how fast a container turns healthy, and how long a failure takes to show.
  • Pick a restart policy, and say what reacts to health (depends_on, --wait, Swarm) and what does not.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

Who reacts when a container turns unhealthy

A Compose service with a health check goes from starting to healthy, which releases docker compose up --wait and a dependent service. A hang later makes it unhealthy and nothing restarts it; only a process exit triggers the restart policy.

Step 1 of 6. One Compose project. web has a health check and restart: unless-stopped. worker waits for web to be healthy, and the deploy runs docker compose up --wait.

Keys: ← → step · Home / End · K play or pause

  1. One Compose project. web has a health check and restart: unless-stopped. worker waits for web to be healthy, and the deploy runs docker compose up --wait.

  2. web starts as starting. During the 20 s start_period Docker runs the check every start_interval, 5 s by default, and failures here do not count.

    start_period: 20s
  3. The first passing check makes web healthy. --wait exits 0, and Compose starts worker, which was waiting on condition: service_healthy.

    condition: service_healthy
  4. The app hangs. Each check now waits out its 5 s timeout, then the next one runs 30 s later. After three failures in a row, web is unhealthy.

    retries: 3Predict the next step web is now unhealthy and has restart: unless-stopped. What does Docker do next? (Restarts web · Nothing: web keeps running, unhealthy · Stops worker)
  5. Nothing restarts. A restart policy reacts to the process exiting, and a hung process has not exited. worker keeps running too: Compose checked health only when starting it.

    Predict the next step Now the process crashes and exits with code 1. What happens? (It stays down: it was unhealthy anyway · It starts again, and health goes back to starting)
  6. The process exits with code 1, and that is what a restart policy watches. unless-stopped starts web again after a short backoff, and its health is starting once more.

    restart: unless-stopped

What is a Docker healthcheck?

A Docker healthcheck is a command that Docker runs inside a running container on a schedule, to ask whether the app still works. Exit code 0 means healthy and 1 means unhealthy; 2 is reserved, so never use it. In a test, a check that exited 127 because its command did not exist counted as a failure too. The answer becomes a status on the container, which docker ps shows as (healthy) or (unhealthy).

Read more (2)

You can declare it in three places. In a Dockerfile, HEALTHCHECK [OPTIONS] CMD command bakes it into the image; only the last one counts, and HEALTHCHECK NONE switches off one inherited from the base image. In Compose, healthcheck: overrides the image's values. Its test is a list that starts with CMD, CMD-SHELL or NONE, a plain string means CMD-SHELL, and disable: true turns the check off. On docker run, the --health-cmd family of flags does the same job, and --no-healthcheck disables it.

CMD runs the program directly, with no shell, so that binary must exist in the image. CMD-SHELL runs the string through the container's default shell, which allows || exit 1 but needs a shell in the image. Docker keeps the first 4096 bytes of each check's output, and docker inspect shows them, which is where you read why a check failed.

How does the health status change over time?

Every container with a check starts as starting. Docker runs the first check one interval after the container starts, and each later check one interval after the previous one finishes. A check that runs past timeout counts as a failure, and Docker kills it. After retries failures in a row the status becomes unhealthy, and the next passing check makes it healthy again. The defaults are 30 s for interval and timeout, 3 retries and no start period.

Read more (2)

start_period is the grace window for a slow boot. Failures inside it do not count towards retries, and Docker runs the check every start_interval, 5 s by default, instead of every interval; start_interval needs Docker Engine 25.0 or later. The first pass inside the window ends it early: from then on, every failure counts.

Each change of status raises a health_status event, so docker events shows healthy, then unhealthy, then healthy again, not every single check. docker inspect shows FailingStreak, the current run of failures, and a log of recent results: in a test on Docker Engine 29.8.1 it held the five most recent.

How fast does this site's check react?

This site's check runs every 30 s with a 5 s timeout, 3 retries and a 20 s start period, and sets no start_interval. Read only the interval and the first check seems 30 s away. It is not: inside the start period Docker checks every 5 s. In a test with an nginx container and these exact timings, the first check ran 5 s after the container started, and docker compose up --wait returned in about 6 s.

Read more (2)

The other direction is slower. With checks 30 s apart, three failures in a row take about a minute and a half: in the same test, a check that failed instantly turned the container unhealthy 90 s after the app broke. A hung app takes longer still, because each failing check first waits out its 5 s timeout. A release whose check failed from its very first second turned unhealthy, and failed --wait, after about 80 s.

Those numbers are a trade-off. A shorter interval notices sooner but runs a process inside the container more often; more retries ride out a slow moment but delay the alarm. Choose them by how long you can afford to serve errors before anything reacts.

Does Docker restart an unhealthy container?

No, not by itself. Docker's docs define every restart policy by how the container exits, and the HEALTHCHECK reference describes a status with no action attached. In a test on Docker Engine 29.8.1, an nginx container with restart: unless-stopped stayed unhealthy for over a minute, with a failing streak of 13 and a RestartCount of 0, and kept answering every request with a 403.

Read more (1)

Swarm mode is the exception. There, if a service's container fails its health checks, its task terminates, and the orchestrator removes it and creates a replacement; lesson 19 covers that. With plain Docker or Compose on one host, something else has to act on unhealthy: a deploy that waits for healthy, a monitor that alerts you, or a script of your own.

What in Compose does react to health?

Two things, and both only while Compose is starting services. depends_on with condition: service_healthy makes Compose wait for the dependency's check to pass before it starts the dependent service. The short form, a plain list of names, waits only until the dependency has started: Compose's docs say that on startup it does not wait for a container to be ready, only running.

Read more (2)

docker compose up --wait waits for services to be running or healthy, implies detached mode, and exits with an error when one turns unhealthy; --wait-timeout sets a ceiling. In a test, a dependency that never passed its check left the dependent service created but never started, and even up -d without --wait failed with dependency failed to start.

Once services are up, Compose is not watching. In the same test, the dependent service kept running after its dependency turned unhealthy. depends_on also takes restart: true, which restarts the dependent service when Compose itself restarts or updates the dependency, not when the container runtime does.

Which restart policy should you pick?

A restart policy tells the Docker daemon what to do when a container's process exits. no, the default, never restarts. on-failure restarts only after a non-zero exit code, and on-failure:3 gives up after three tries. always restarts after any exit. unless-stopped does the same, except that a container you stopped stays stopped, even after the daemon restarts.

Read more (3)

Two rules apply to all of them. A container you stop with docker stop stays down until the daemon restarts or you start it again, so a restart policy never fights you. And Docker waits before each restart, starting at 100 ms and doubling up to a minute, then resets once the container has run for 10 s. In a test, a container that exited every 2 s had been restarted 8 times after 30 s.

A daemon restart includes a server reboot, since Debian and Ubuntu start Docker on boot. always and unless-stopped bring back containers that were running, and of the two, only always also revives one you had stopped. on-failure goes by the last exit code. Docker's docs say it does not restart on a daemon restart, and in a test that held for a container that had exited 0; one that had been killed, exit code 137, came back, even one stopped by hand.

For a web service on one server, unless-stopped is the usual choice, and it is this site's: crashes come back, and a docker stop for maintenance survives a reboot. Do not also wrap the container in systemd or supervisor; Docker's docs warn that combining a restart policy with a host process manager creates conflicts.

Why does this site check robots.txt with wget on 127.0.0.1?

Because of what is inside the image. The runtime stage is node:20-alpine, which has BusyBox wget and no curl, and the exec form CMD needs no shell. --spider only checks that the URL exists, exiting 0 if it does; in a test it still sent a GET. --no-verbose and --tries=1 are GNU wget options that BusyBox accepts without listing them in its help, so the timeout and Docker's retries do the deciding. robots.txt is a cheap route that proves the Node server answers, not that every page renders.

Read more (1)

127.0.0.1 is spelled out on purpose. The check runs inside the container, where localhost maps to both 127.0.0.1 and ::1, and BusyBox wget tried ::1 first. The app listens on 0.0.0.0, which covers IPv4 only, so in a test on node:20-alpine, wget to localhost:3000 got Connection refused while the same request to 127.0.0.1:3000 passed.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

docker-compose.yml:10
    restart: unless-stopped

One line decides what happens after an exit. A crash brings the container back after a short backoff, and a docker stop for maintenance keeps it down, even across a reboot. An unhealthy status changes nothing here, because the process has not exited.

docker-compose.yml:29-42
    healthcheck:
      test:
        [
          "CMD",
          "wget",
          "--no-verbose",
          "--tries=1",
          "--spider",
          "http://127.0.0.1:3000/robots.txt",
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

Read the numbers as a timeline. Inside the 20 s start period Docker checks every 5 s, the default start_interval, so a fresh container is healthy within seconds. After that a check runs every 30 s and gives up after 5 s, so three failures in a row take about 90 s to show as unhealthy. The exec form runs BusyBox wget with no shell, against the container's own 127.0.0.1.

.github/workflows/deploy.yml:241-244
            docker compose up -d --remove-orphans || {
              docker rm -f matthewswong-portfolio 2>/dev/null || true
              docker compose up -d --remove-orphans
            }

up -d returns once the container has started, so the health check never decides whether this step passes. The fallback handles a name clash from the first cutover. If you add --wait, add it to both up lines: in a test with a one-service file, --wait on the first line alone failed, the fallback started the same release again without waiting, and the script ended successfully.

.github/workflows/deploy.yml:329-332
      # The container answers within seconds of `up -d`; this short pause
      # saves most runs from spending a retry on a 502.
      - name: Wait for the new container
        run: sleep 20

The job after the deploy waits by the clock, not by health: 20 s, and its script retries if the site still answers 502. That suits a ping to search engines, where a miss costs little. A deploy that must know the release works waits on the health check instead, with docker compose up --wait; a test container with the same timings reported healthy in about 6 s.

What breaks

Mistakes that cost real time

  • Unhealthy for hours, and nothing happened

    A failing check flips a status and raises an event. It does not restart, stop or unroute the container, so a broken app keeps serving errors until someone looks. Make something act on it: --wait at deploy time, a monitor that alerts on the status or on an outside URL, or Swarm mode, which replaces a task whose container fails its checks.

  • Connection refused in the health log, yet the app works

    The check calls localhost, BusyBox wget tries ::1 first, and the app listens on 0.0.0.0, which is IPv4 only. The log says can't connect to remote host: Connection refused on every check. Write 127.0.0.1 in the check, as this site does, or make the app listen on IPv6 as well.

  • curl: not found, on every check

    A check copied from a tutorial calls curl, and the image does not have it: node:20-alpine ships BusyBox wget and no curl. In a test the log showed /bin/sh: curl: not found with exit code 127, and the container turned unhealthy while the app answered fine. Use a tool the image has, and test the exact command with docker exec first.

  • A crash loop that never ends

    always and unless-stopped restart a container that exits at once, forever, with a growing pause up to a minute. docker ps shows Restarting, and RestartCount keeps climbing. Read docker logs for the reason, and give jobs that should give up on-failure with a limit, such as on-failure:3.

What each restart policy does, as Docker documents it and as tests on Docker Engine 29.8.1 and 29.9.1 showed.
no (default)Stays downStays downStays downKeeps running
on-failure[:N]Stays downRestarted, at most N timesBack only if its last exit code was not 0Keeps running
alwaysRestartedRestartedBack, even if you had stopped itKeeps running
unless-stoppedRestartedRestartedBack, unless you had stopped itKeeps running
Try it yourself

Do it on your own machine

All you need is Docker and curl on a laptop; every port stays on 127.0.0.1 and every container name starts with hc- or health-lab. You will watch a check go healthy, break it, heal it, gate a Compose start on it, and crash containers under each policy.

  1. Start nginx with a health check that mirrors this site's, with short timings. Run docker ps right away and again 10 seconds later: starting, then healthy.

    docker run -d --name hc-web --restart unless-stopped -p 127.0.0.1:8090:80 \
      --health-cmd "wget --no-verbose --tries=1 --spider http://127.0.0.1/" \
      --health-interval 5s --health-timeout 3s --health-retries 3 --health-start-period 10s \
      nginx:alpine
    docker ps --filter name=hc-web --format '{{.Names}} {{.Status}}'
  2. Read the health record: the status, the current failing streak, and each recent check's exit code and output.

    docker inspect --format '{{.State.Health.Status}} streak={{.State.Health.FailingStreak}}' hc-web
    docker inspect --format '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{end}}' hc-web
  3. Break it by deleting the page the check asks for. About 15 seconds later the status is unhealthy, yet nginx still answers (403) and RestartCount is 0, despite unless-stopped.

    docker exec hc-web rm /usr/share/nginx/html/index.html
    # about 15 seconds later:
    docker ps --filter name=hc-web --format '{{.Names}} {{.Status}}'
    curl -I http://127.0.0.1:8090/
    docker inspect --format 'restarts={{.RestartCount}}' hc-web
  4. Heal it. One passing check is enough to be healthy again. The events list shows each change of status, not every check; if the last healthy line is missing, run it again a few seconds later.

    docker exec hc-web sh -c 'echo ok > /usr/share/nginx/html/index.html'
    # about 10 seconds later:
    docker inspect --format '{{.State.Health.Status}}' hc-web
    docker events --since 5m --until 0s --filter container=hc-web --filter event=health_status
  5. Make an empty folder called health-lab and save this as compose.yaml. CHECK_PATH lets you break the check from the command line, and worker waits for api to be healthy.

    services:
      api:
        image: nginx:alpine
        restart: unless-stopped
        ports:
          - "127.0.0.1:8091:80"
        healthcheck:
          test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1/${CHECK_PATH:-}"]
          interval: 5s
          timeout: 3s
          retries: 3
          start_period: 10s
      worker:
        image: alpine:3
        command: sleep 600
        depends_on:
          api:
            condition: service_healthy
  6. In that folder, start it with --wait: exit 0 within seconds. Then start it again with a check that can never pass. After about 20 seconds Compose prints dependency failed to start, exits 1, and worker is Created but never started.

    docker compose up -d --wait; echo "exit=$?"
    docker compose ps
    docker compose down
    CHECK_PATH=missing docker compose up -d --wait; echo "exit=$?"
    docker compose ps -a
  7. Crash containers under three policies, each exiting after 2 seconds. About 20 seconds later: no has 0 restarts, on-failure:3 gave up at 3, and always keeps going. Then stop the always one by hand: it stays down.

    docker run -d --name hc-no --restart no alpine:3 sh -c 'sleep 2; exit 1'
    docker run -d --name hc-onf --restart on-failure:3 alpine:3 sh -c 'sleep 2; exit 1'
    docker run -d --name hc-always --restart always alpine:3 sh -c 'sleep 2; exit 0'
    # about 20 seconds later:
    docker inspect --format '{{.Name}} restarts={{.RestartCount}}' hc-no hc-onf hc-always
    docker stop hc-always
    docker ps -a --filter name=hc- --format '{{.Names}} {{.Status}}'
  8. Clean up the containers, then, inside health-lab, the Compose project.

    docker rm -f hc-web hc-no hc-onf hc-always
    docker compose down
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.

  1. 1.This site's check has interval: 30s and start_period: 20s, and no start_interval. When does the first check run after the container starts?
  2. 2.A container with restart: on-failure:3 hangs and has been unhealthy for ten minutes. How many times has Docker restarted it?
  3. 3.An Alpine-based image checks wget --spider http://localhost:3000/, and the app listens on 0.0.0.0. The health log says Connection refused, yet the app answers. What is wrong?
From earlier lessons

Two questions from earlier lessons

Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.

  1. 1.A directory is drwx--x--x and owned by alice. What can bob, who is not in its group, do with it?
  2. 2.A new named volume is mounted at /app/uploads, a path the image does not contain. The app runs as uid 1001. What happens when it writes there?

From: 22. Linux File Permissions Explained: ls -l, chmod, chown, umask · 20. Docker Volume vs Bind Mount: Where Container Data Lives

Think it through

Answer in your head, then check

  1. 1.What do exit codes 0, 1 and 2 mean to a health check?Show the answer

    Answer:0 is healthy, 1 is unhealthy, and 2 is reserved, so never return it. In a test, other non-zero codes, such as 127 for a missing command, counted as failures too.

  2. 2.worker has depends_on: api with condition: service_healthy. An hour after startup, api turns unhealthy. What does Compose do?Show the answer

    Answer:Nothing. Compose checks the condition only while it starts services. In a test, the dependent service kept running after its dependency turned unhealthy.

  3. 3.You stopped a container with docker stop, and then the server rebooted. Does it come back under always? Under unless-stopped?Show the answer

    Answer:Under always, yes: a manual stop holds only until the daemon restarts. Under unless-stopped, no: it stays down until you start it yourself. That difference is the reason to pick unless-stopped for a service you sometimes stop for maintenance.

Go deeper

Related deep dives on this site