Skip to Content
Add a HEALTHCHECK That Actually Fails
Advanced
0 likes
Tutorials

Add a HEALTHCHECK That Actually Fails

Break an app on purpose and compare three health checks, then put the one that works in the Dockerfile. After this, unhealthy means the app is broken, not the check.

A health check that never fails is worse than none, because it tells everyone the service is fine while it is broken. A health check that always fails is just as useless: people learn to ignore "unhealthy". This page breaks a small app on purpose and runs three health checks against it, so you can see which one actually reports the problem. Run on Docker Desktop 4.85.0 (Engine 29.6.2) with python:3.14-alpine.

The app

A tiny web server with a home page and a /health page. The home page always works. /health returns 500 as soon as the file /tmp/broken exists, which is how I simulate a lost database connection.

import http.server, os

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        broken = os.path.exists("/tmp/broken")
        if self.path == "/health":
            code, body = (500, b"broken\n") if broken else (200, b"ok\n")
        else:
            code, body = 200, b"home page\n"
        self.send_response(code)
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass

http.server.HTTPServer(("", 8000), Handler).serve_forever()
FROM python:3.14-alpine
COPY server.py /server.py
CMD ["python", "/server.py"]

Three checks

Set-Location health-demo
docker build -q -t health-app . | Out-Null
$h = "--health-interval=1s", "--health-retries=2", "--health-start-period=0s"
docker run -d --name hc-curl @h --health-cmd "curl -f http://127.0.0.1:8000/health || exit 1" health-app | Out-Null
docker run -d --name hc-home @h --health-cmd "wget -q -O /dev/null http://127.0.0.1:8000/" health-app | Out-Null
docker run -d --name hc-good @h --health-cmd "wget -q -O /dev/null http://127.0.0.1:8000/health" health-app | Out-Null
Start-Sleep -Seconds 5
"--- app working ---"
foreach ($c in "hc-curl", "hc-home", "hc-good") { "{0}: {1}" -f $c, (docker inspect $c --format "{{.State.Health.Status}}") }
foreach ($c in "hc-curl", "hc-home", "hc-good") { docker exec $c touch /tmp/broken }
Start-Sleep -Seconds 5
"--- app broken ---"
foreach ($c in "hc-curl", "hc-home", "hc-good") { "{0}: {1}" -f $c, (docker inspect $c --format "{{.State.Health.Status}}") }
--- app working ---
hc-curl: unhealthy
hc-home: healthy
hc-good: healthy
--- app broken ---
hc-curl: unhealthy
hc-home: healthy
hc-good: unhealthy
  • hc-curl was unhealthy from the start. The image has no curl, so the check failed while the app was perfectly fine. This is the most common broken health check I see.
  • hc-home stayed healthy after the app broke, because it only asked for the home page.
  • hc-good was healthy while the app worked, and unhealthy after it broke. That is the only one doing its job.

Read why a check failed

Docker keeps the output of the last few checks. It tells you if the check found a real problem or is itself broken:

foreach ($c in "hc-curl", "hc-good") {
  $last = (docker inspect $c --format "{{json .State.Health.Log}}" | ConvertFrom-Json)[-1]
  "{0}: exit {1}, output: {2}" -f $c, $last.ExitCode, $last.Output.Trim()
}
hc-curl: exit 1, output: /bin/sh: curl: not found
hc-good: exit 1, output: wget: server returned error: HTTP/1.0 500 Internal Server Error

Exit code 127 with "not found" means the check command is missing. A normal failure with an HTTP error means the check works and found a problem.

Put the good one in the Dockerfile

FROM python:3.14-alpine
COPY server.py /server.py
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \
  CMD wget -q -O /dev/null http://127.0.0.1:8000/health || exit 1
CMD ["python", "/server.py"]
docker build -q -f Dockerfile.good -t health-app-good . | Out-Null
docker run -d --name hc-image health-app-good | Out-Null
Start-Sleep -Seconds 3
docker inspect hc-image --format "{{.State.Health.Status}}"
starting

starting is the state during the start period. After the first passing check it changes to healthy.

A good health check

  • Checks the thing that matters: a /health page that tests what the app needs, such as the database connection.
  • Uses a tool that is in the image. Alpine images have wget (from BusyBox) but not curl. Python images can use python -c with urllib.
  • Uses 127.0.0.1, not localhost, so it does not depend on IPv6 settings.
  • Has a timeout, so a hung app counts as unhealthy instead of hanging the check.
  • Has a start_period long enough for a slow start.
  • Is cheap. It runs every few seconds forever.

⚠ Warning: Plain Docker and Compose only mark a container unhealthy. They do not restart it. Use the status for depends_on, docker compose up --wait and your monitoring, or make the app exit when it is broken so the restart policy can act.

Related course: Docker Advanced.

About

Break an app on purpose and compare three health checks, then put the one that works in the Dockerfile. After this, unhealthy means the app is broken, not the check.

Responsible
Jay P
Last Update
08/04/2026
Reading Time
5 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.