Docker Healthchecks: Stop Trusting ‘Up’

Docker Healthchecks: Stop Trusting ‘Up’

Add Docker healthchecks to Compose services so a running container is not mistaken for a working self-hosted app.

💡 Disclosure: This article contains affiliate links. If you make a purchase through these links, we may earn a small commission at no extra cost to you. This helps support the site and keeps the content free.

A green docker compose ps output can be a lie. A process is running, Docker is happy, and the application behind it is stuck waiting for a database, returning 500s, or serving an old broken state.

I stopped treating Up as a useful answer after seeing it during an outage. The container had been alive for hours. The service was not. A healthcheck is the small line of defense that makes Docker test something closer to what users actually need.

What a Docker healthcheck does

A healthcheck runs a command inside the container on a schedule. Docker labels the container healthy, unhealthy, or starting based on that command’s exit status.

It does not restart a failed container by itself. That misconception causes trouble. Its job is to expose application health to you, Compose dependencies, monitoring, and your deployment scripts.

For an HTTP app, a basic check can be as small as this:

services:
  app:
    image: ghcr.io/example/app:1.4.0
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8080/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

That says: wait thirty seconds for startup, then probe the local health endpoint every thirty seconds. Three failed checks in a row mark the container unhealthy. The endpoint must return a successful HTTP status for the check to pass.

Use 127.0.0.1, not the public domain, when the goal is an in-container application check. You are testing the app rather than DNS, TLS, the reverse proxy, and the entire internet route at once. Those are worth monitoring too, just separately.

Check the thing that can actually fail

A TCP port check is better than nothing, but it is not my first choice. A server can accept a connection while its database pool is exhausted or its migrations have failed.

Prefer the application’s documented readiness or health endpoint. Common examples are /health, /healthz, /ready, or /api/health. Read the project documentation before inventing a route; an endpoint that always returns 200 is only a decorative healthcheck.

If the image has curl but not wget, use this instead:

healthcheck:
  test: ["CMD-SHELL", "curl --fail --silent http://127.0.0.1:3000/health || exit 1"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 45s

Do not install debugging tools in a production image just to make a healthcheck work. Check what the upstream image already contains, or use the app’s native command if it has one. A Python app may have a tiny Python probe available; a database image often ships a purpose-built readiness tool.

Postgres has its own check

Postgres is a good example because a running database process is not necessarily ready to accept connections. The official image includes pg_isready:

services:
  db:
    image: postgres:17.6
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s

Notice the double dollar signs. Compose needs them so it does not interpolate those variables on the host before the command reaches the container. This is exactly the kind of tiny detail that turns a supposedly safe Compose file into a Friday-evening puzzle.

For MariaDB or MySQL, use the client already provided by the image:

healthcheck:
  test: ["CMD-SHELL", "mariadb-admin ping -h localhost -u root -p$${MYSQL_ROOT_PASSWORD} --silent"]
  interval: 10s
  timeout: 5s
  retries: 5
  start_period: 30s

Keep secrets out of the rendered Compose output and your shell history. Passing a password in a healthcheck command may be unavoidable with a stock image, but it is another reason to inspect what your tools log.

Make Compose wait for the database

Healthchecks become much more useful when a dependent service waits for them. Here is the pattern I use for an app that should not start before Postgres is ready:

services:
  app:
    image: ghcr.io/example/app:1.4.0
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/app

  db:
    image: postgres:17.6
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

This controls startup order, not ongoing recovery. If Postgres becomes unhealthy later, Compose will not automatically restart the app. Your application still needs sane database retry behavior, and your monitoring still needs to tell you when the public service fails.

That distinction matters. Healthchecks are plumbing, not magic.

Inspect and test the result

After deploying, check the actual state instead of assuming the YAML was correct:

docker compose up -d
docker compose ps
docker inspect --format '{{.State.Health.Status}}' app
docker inspect --format '{{range .State.Health.Log}}{{.Output}}{{end}}' app

The last command prints the healthcheck output. It is usually the fastest way to learn that curl is missing, the endpoint path is wrong, or the app simply needs a longer start_period.

Test the failure case once. Temporarily point the probe at a nonexistent path, confirm the service becomes unhealthy, then put the real probe back. An untested alert path is just optimism wearing a dashboard.

Keep an external check too

An internal healthcheck cannot see a broken reverse proxy, an expired TLS certificate, or a firewall rule that cut users off. Pair it with an external HTTP monitor such as Uptime Kuma, which should probe the public URL from another machine when possible.

I like both layers: Docker answers “is the process serving its own health endpoint?” and an external monitor answers “can someone actually reach the service?” Those are different questions, and both have embarrassed plenty of homelabs.

🚀NordVPN

Use a reliable VPN when managing your server from untrusted networks.

Get NordVPN →

Affiliate link — we may earn a commission at no extra cost to you.

Do this now: add one healthcheck to the Compose service you care about most, run docker compose config --quiet, then deliberately make its probe fail once. If you cannot see the failure, you have not finished monitoring it.

Stay in the loop 📬

Get self-hosting tutorials, tool reviews, and infrastructure tips delivered to your inbox. No spam, unsubscribe anytime.

Join 0 self-hosters. Free forever.