Skip to content

Health checks

Why every service needs one

A health check is a command the container runtime runs on a schedule to ask a container whether it is actually working. Without one, Docker only knows whether the process is still running, which is a much weaker claim: a server that started, bound its port, and then broke is still "running".

For this project the practical consequence is about deployments. Coolify does a rolling replacement: it starts the new container, waits, then stops the old one. With a health check, "waits" means waits for the new container to prove it works, and a container that never becomes healthy never receives traffic, so a broken build leaves the previous version serving. Without one, Coolify has nothing to wait for, assumes the new container is fine, and stops the old one regardless. A build that compiles but crashes on startup takes the site down.

That is the whole argument: a health check is what makes a failed deploy a non-event instead of an outage.

Always use 127.0.0.1, never localhost

This is the single most important detail on this page, and it has already broken a health check on this project.

Inside a container, localhost resolves to both 127.0.0.1 and ::1, the IPv6 loopback. Our servers bind IPv4 only:

  • The application's Node server binds 0.0.0.0, which is IPv4.
  • The documentation site's nginx uses listen 80, which is IPv4 only. Listening on IPv6 as well would require listen [::]:80.

So when the checking tool resolves localhost, picks the IPv6 address, and connects, it gets a connection refused. The check fails. The container is reported unhealthy and the deployment is rolled back, while the service is in fact serving traffic perfectly.

The symptom is maddening precisely because the site works: you can open it in a browser while Coolify insists the container is broken.

Use the literal address 127.0.0.1 and the ambiguity disappears.

Settings

Both services are configured through Coolify's Healthcheck section, using the HTTP request check type.

FieldApplicationDocumentation
MethodGETGET
SchemeHTTPHTTP
Host127.0.0.1127.0.0.1
Port300080
Path//
Expected code200200
Expected response text(empty)(empty)

The scheme is plain HTTP because the check runs inside the container, behind TLS termination. Traefik holds the certificate; the container itself never speaks HTTPS.

Leave Expected response text empty. It makes the check assert that the body contains a given string, which sounds stricter but mostly creates false failures: the body is a rendered HTML page whose content changes, and a check that fails because of a copy edit is worse than no extra assertion.

Timing

FieldValueWhy
Interval5How often to check, in seconds
Timeout5How long one check may take before counting as failed
Retries3Consecutive failures before the container is declared unhealthy
Start period5Grace time after start, during which failures do not count

Interval times retries is how long a genuinely broken service stays marked healthy: at 5 and 3, about fifteen seconds. Raising retries to 10 stretches that to nearly a minute, which delays both the alert and the rollback for no benefit at this scale.

The start period matters for slow-starting services. Neither of ours is slow: nginx serves immediately, and the Node server binds its port in well under a second. Five seconds is comfortable.

Checking it worked

The status indicator at the top of the application page should change from Running (no healthcheck) to Running (healthy) within a few intervals.

Running (no healthcheck) is not an error. It means exactly what it says: the container is up and nothing is verifying it. That is the state to get out of.

To see the result directly on the server:

sh
docker ps --format "{{.Names}}\t{{.Status}}"

A container with a check shows (healthy) or (unhealthy) beside its uptime. One without shows neither.

To see why a check is failing, ask Docker for the last few attempts:

sh
docker inspect <container> --format '{{json .State.Health}}' | head -c 2000

That returns the exit code and captured output of recent probes, which usually names the problem immediately. A connection refused there, on a service you can reach in a browser, is almost always the IPv6 issue described above.

Before adding a check to a new service

Three questions worth answering first.

What address does the process bind? If it binds 127.0.0.1 only, it is unreachable from outside the container regardless of the check. If it binds 0.0.0.0, it is IPv4, and the check must use 127.0.0.1.

Is the path cheap? The check runs every few seconds forever. Point it at something static. Do not point it at a page that queries the database, or the check becomes a steady background load and, worse, reports the service unhealthy whenever the database is briefly slow.

Does the path really return 200? Redirects do not count. A path that returns 301 to the canonical URL fails a check expecting 200.

Internal engineering documentation.