Skip to content

Health checks

A /ping that returns 200 OK proves one thing: the process accepts connections. That is worth having, and it is not enough. It does not tell you whether the service reaches its database, and it does not tell you whether the proxy in front of it still routes the public hostname to your container.

This page describes the operational endpoints we want on every service and which machine acts on each. For the code, the probe configuration and the timings, see health check endpoints.

A health check is a control signal for one automated actuator, not a status report. Before adding a check, answer: what machine reads this, and what does it do when the check fails? If the answer is “nothing, a human looks at it”, then it is monitoring, and it belongs in uptime-kuma or Grafana rather than in a gate.

From that follows the rule that keeps health checks from causing the outages they were meant to prevent:

The depth of a check must be inversely proportional to the blast radius of the reaction it triggers.

A check that restarts containers or removes them from routing has to be shallow, because a wrong answer takes the service down. A check that only pages a human can be as deep as you like.

The classic way to get this wrong is to let the container health check query the database. When the database blips, every container turns unhealthy at the same moment, the proxy stops routing to all of them, and a slow database becomes a hard outage. The service could have served a 500 with a useful error page. Instead the hostname 404s.

The second thing to get right is the axis a check sits on. A check running inside the container against localhost sees connection pools, migrations and queue depth, and is blind to DNS, TLS, the proxy and routing. A check from outside sees the whole user path and cannot say why it broke. Neither substitutes for the other, and deepening an inside-out check never moves it onto the other axis.

Our two deploy targets hand a failed check to different machinery, and which actuator reads a check is the whole of what makes it safe or dangerous.

On Coolify:

SignalRead byReaction
Process exitsDocker restart: policyThe container restarts
Container HEALTHCHECKCoolifyGates the rolling update, and rolls a bad deploy back
HTTP probe on a public hostnameuptime-kumaPages the team in Mattermost
MetricsPrometheus, GrafanaAlerts on a trend over a window, never on a single sample

Note what is absent: plain Docker does not restart a container because its health check failed, and neither of the proxies in front of our Coolify servers takes an unhealthy container out of routing. Traefik can be configured to, and the itslanguage servers run caddy-docker-proxy, which builds its routes from container labels and offers no option to filter on health at all. So on Coolify the container health check is a deploy signal and nothing else: an unhealthy container that stays up keeps receiving traffic, and process death is what the restart: policy handles.

That makes the deploy gate the only thing acting on the check, which is worth knowing before choosing its depth. A check that is too deep will not take a healthy service out of routing here. It will refuse to let you ship.

On Kubernetes, every probe has its own actuator, and the restart one is real:

SignalRead byReaction
startupProbekubeletHolds off the other probes; kills the pod if boot never finishes
livenessProbekubeletRestarts the container
readinessProbekubeletRemoves the pod from the Service endpoints, so it gets no traffic
Ingress or probeuptime-kumaPages the team in Mattermost
EndpointAnswersDepthActuator
/health/liveIs the process up?Constant UP, no I/O at allRestarts or blocks a deploy
/health/readyShould this instance get traffic?Drain state, plus hard dependenciesRouting, and pages a human
/versionWhich build is answering?ConstantA human reads it

/health/live answers UP and touches nothing. That is not a placeholder to improve on later, it is the correct and complete implementation. It has the most violent actuator behind it: on Kubernetes a failure restarts the container, on Coolify it blocks a deploy, and neither reaction repairs a broken dependency. Anything /health/live touches becomes a thing that can restart your fleet or stop you shipping a fix during an incident. An existing /ping already does the right amount of work; only its path and body change.

For that constant to mean anything, finish boot work before serving. Run migrations and cache warming before the server answers, so “the port answers” is itself the signal that boot completed and no endpoint has to carry it. The guide covers how to sequence that.

/health/ready has two legitimate inputs. The first is whether the instance is draining because it is being shut down, which is worth reporting only where something reads readiness to make routing decisions. The second is the dependencies the service genuinely cannot work without. Be strict about “cannot”: the test is whether the service can still produce a useful response, not whether it can serve every route. A backend that still returns cached data, static assets or a real error page has no hard dependency.

That strictness matters because readiness is fleet-wide. Every replica evaluates the same dependency at the same moment, so one database blip empties the Service endpoints all at once, and neither Kubernetes nor our proxies have a fail-open to catch it. A total outage replaces what would have been a degraded service returning honest errors.

Probing /health/ready from uptime-kuma covers both axes in a single monitor. The request crosses DNS, TLS and the proxy or ingress to get there, and the response asserts what the application itself can reach. One monitor per public hostname is enough: backends point at /health/ready, frontends at /.

Whether a service needs the endpoint at all depends on the target, because the two inputs are not both live everywhere. On Kubernetes it is always warranted: the readiness probe gates Service endpoint membership, so both draining and dependency state change routing. On Coolify neither does. Nothing routes on readiness there, and the rolling update already provides the overlap draining exists for, which leaves uptime-kuma as the only reader. So a Coolify service with a hard dependency needs /health/ready, because that is what makes the monitor see past the front door, and one without a hard dependency does not: the endpoint would answer exactly what /health/live answers, and the monitor for a frontend points at / anyway.

Do not report draining on Coolify. Nothing there acts on it, so the flag buys no shed request and costs the monitor a 503 to find mid-deploy. What the server does when it receives SIGTERM still matters, and that is a question about the server rather than about an endpoint. See shutting down.

/version is the cheapest of them to add. It turns “which commit is live on this hostname?” from an SSH session into a curl, and it is what makes verifying a deploy or a proxy change a diff instead of an investigation.

  • Soft dependencies. Mail, object storage, analytics, a search index the UI degrades gracefully without. If the service can still answer requests without it, its failure does not belong in /health/ready. Expose it as a metric.
  • Other services’ health endpoints. Chaining readiness checks is how one service’s bad afternoon becomes everyone’s outage.
  • Anything expensive. These endpoints run every few seconds for the life of the container. No full table scans, no writes, no external API calls.
  • Business data. These endpoints are unauthenticated. Do not return record counts, configuration, dependency hostnames, or stack traces. If you want a detailed view, put it behind auth on a separate endpoint.

Verification that does real work, such as counting rows through the application’s own connection to prove a deploy landed correctly, is valuable and belongs in a one-shot script run at a deploy boundary. Never promote it to a continuous check.

  • /health/live answers a constant UP and performs no I/O
  • Boot work finishes before the server serves, so nothing answers until the service is up
  • The container HEALTHCHECK and, on Kubernetes, livenessProbe and startupProbe point at /health/live
  • /health/ready checks hard dependencies only, cached and with a timeout, and reports draining on Kubernetes; a Coolify-only service with no hard dependency needs no /health/ready at all
  • Nothing in the application replaces the server’s own SIGTERM handling
  • readinessProbe points at /health/ready
  • /version reports the commit SHA and build time
  • Every public hostname has an uptime-kuma monitor, backends pointing at /health/ready
  • No endpoint above returns business data or dependency hostnames

There is no standard path. There is a standard concept.

The liveness, readiness and startup split is settled across every major ecosystem. The URL is not. No RFC defines one, health does not appear in the IANA well-known URI registry, and the closest IETF work, Health Check Response Format for HTTP APIs, expired in 2022 without being adopted by a working group, so cite it as a draft rather than as a standard.

One ratified specification does fix the paths, and it is the one we follow. MicroProfile Health 4.0 (Eclipse Foundation, 2021) requires /health/live, /health/started and /health/ready, and defines the response body as UP or DOWN with a checks array, mapped onto 200 and 503. Quarkus, Open Liberty and WildFly implement it.

We take that wire contract and nothing else. The rest of the specification is Java: CDI annotations, a HealthCheck interface, a response builder, bean discovery. None of it ports, and none of it is needed to serve the same paths and the same JSON from any language.

We also serve two of its three paths rather than all three. /health/started is there for runtimes that accept requests while still booting; ours do not, since boot work completes before the server serves, so a third path would never answer anything /health/live does not. The startup semantic still exists, carried by the startup probe’s budget instead of by a URL. Deviating is the point of knowing what the spec says: take the parts that earn their place, and record which ones you left.

The alternatives are conventions rather than specifications:

PathUsed by
/livez, /readyzKubernetes API server, Node.js Reference Architecture
/healthz/live, /healthz/readyASP.NET Core
/actuator/health/livenessSpring Boot Actuator, which also serves /livez and /readyz on request

The trailing z throughout is Google’s “z-pages” convention (/varz, /statusz, /rpcz), meant to stop operational endpoints colliding with real routes; Kubernetes documents its own family. The Node.js Reference Architecture rejects a bare /healthz because it does not say whether it means liveness or readiness, a criticism /health/live answers directly.

For gRPC there is a real cross-language standard, the gRPC Health Checking Protocol, which uses a service method rather than a path.