# 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.

URL: https://www.matthewswong.com/en/learn/devops-from-zero/docker-healthcheck-restart-policy/

DevOps from zero · Lesson 23 of 25 · Reviewed on 18 October 2026 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> 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.

## 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.

1. **Meet the parts.** 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. **Starting: a grace window.** 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. **One pass: healthy.** 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. **Three failures in a row.** 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: 3
   ```

   *Predict:* web is now unhealthy and has restart: unless-stopped. What does Docker do next? (Restarts web / Nothing: web keeps running, unhealthy / Stops worker)
5. **Unhealthy is only a label.** 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:* 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. **An exit is different.** 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).

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.

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.

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.

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.

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.

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.

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.

## The real config behind this lesson

`docker-compose.yml:10`

```yaml
    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`

```yaml
    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`

```bash
            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`

```yaml
      # 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

- **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.

| Policy | Exits with code 0 | Exits with an error | After a daemon restart or reboot | Running but unhealthy |
| --- | --- | --- | --- | --- |
| no (default) | Stays down | Stays down | Stays down | Keeps running |
| on-failure[:N] | Stays down | Restarted, at most N times | Back only if its last exit code was not 0 | Keeps running |
| always | Restarted | Restarted | Back, even if you had stopped it | Keeps running |
| unless-stopped | Restarted | Restarted | Back, unless you had stopped it | Keeps running |

## Try it yourself

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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   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.

   ```bash
   docker rm -f hc-web hc-no hc-onf hc-always
   docker compose down
   ```

## Quick quiz

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?**
   A. After 30 s, one interval in
   B. After about 5 s, because the start period checks every start_interval
   C. After 20 s, when the start period ends
   Answer: B. Inside the start period Docker runs the check every start_interval, 5 s by default on Engine 25.0 and later. In a test with these timings the first check ran 5 s after start, and --wait returned in about 6 s.

2. **A container with restart: on-failure:3 hangs and has been unhealthy for ten minutes. How many times has Docker restarted it?**
   A. Three times, then it gave up
   B. Once, after the third failed check
   C. Not once: the process never exited
   Answer: C. Restart policies react to the process exiting, and a hung process is still running. Unhealthy is a status; on plain Docker something else, such as a monitor or a deploy that waits, has to act on it.

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?**
   A. BusyBox wget tried ::1 first, and the app listens on IPv4 only
   B. Health checks run on the host, where port 3000 is not published
   C. --spider has to download the whole file first
   Answer: A. Inside the container localhost maps to 127.0.0.1 and ::1, and BusyBox wget tried ::1 first. Nothing listens there, so the check fails. Write 127.0.0.1, as this site's check does.

## Think it through

- **What do exit codes 0, 1 and 2 mean to a health check?** 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.
- **worker has depends_on: api with condition: service_healthy. An hour after startup, api turns unhealthy. What does Compose do?** Nothing. Compose checks the condition only while it starts services. In a test, the dependent service kept running after its dependency turned unhealthy.
- **You stopped a container with docker stop, and then the server rebooted. Does it come back under always? Under unless-stopped?** 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

- [NestJS Health Checks with Terminus for Kubernetes & Docker](https://www.matthewswong.com/en/blog/nestjs-terminus-health-checks/)
- [Nginx Load Balancer in Production: Configuration, Health Checks, and Failover](https://www.matthewswong.com/en/blog/load-balancer-production-config/)
- [Docker Swarm: Container Orchestration for Smaller Teams](https://www.matthewswong.com/en/blog/docker-swarm-orchestration/)

## Sources

- [Docker docs: Dockerfile reference, HEALTHCHECK](https://docs.docker.com/reference/dockerfile/#healthcheck)
- [Docker docs: Compose services reference (healthcheck, restart, depends_on)](https://docs.docker.com/reference/compose-file/services/)
- [Docker docs: Start containers automatically (restart policies)](https://docs.docker.com/engine/containers/start-containers-automatically/)
- [Docker docs: docker container run (--health-* flags, restart delay)](https://docs.docker.com/reference/cli/docker/container/run/)
- [Docker docs: docker compose up (--wait, --wait-timeout)](https://docs.docker.com/reference/cli/docker/compose/up/)
- [Docker docs: Control startup order in Compose](https://docs.docker.com/compose/how-tos/startup-order/)
- [Docker docs: docker system events (health_status)](https://docs.docker.com/reference/cli/docker/system/events/)
- [Docker docs: docker container ls (health filter)](https://docs.docker.com/reference/cli/docker/container/ls/)
- [Docker docs: How services work in Swarm mode](https://docs.docker.com/engine/swarm/how-swarm-mode-works/services/)
- [Docker docs: Linux post-installation steps (start on boot)](https://docs.docker.com/engine/install/linux-postinstall/)
- [BusyBox command help (wget --spider)](https://busybox.net/downloads/BusyBox.html)

---

- HTML version: https://www.matthewswong.com/en/learn/devops-from-zero/docker-healthcheck-restart-policy/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/devops-from-zero/docker-healthcheck-restart-policy/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
