Nginx Health Check: Passive, Active and the Licence Wall
An nginx health check means one of two configs, and the active kind is NGINX Plus only. Here is what open source gives you and how to cover the rest.
Setting up an nginx health check means answering two separate questions first. Are you testing the upstream servers behind the proxy, or nginx itself? The configuration is different for each, and the first one runs into a licence wall that the documentation mentions only in passing.
Does an nginx health check exist in open source?
Not the active kind. The health_check directive lives in ngx_http_upstream_hc_module, and that module ships only with NGINX Plus, the commercial build. The module reference says so in its first line, under a heading most readers scroll past.
Open source nginx gets passive checking instead. It never sends a probe of its own. It watches the requests real clients make and counts the ones that fail.
| Approach | Build | Directive | Needs traffic |
|---|---|---|---|
| Passive | open source and Plus | max_fails, fail_timeout | yes |
| Active | NGINX Plus only | health_check, match | no |
| Self-check | both | a location returning 200 | no |
| Third-party active | open source plus a module | varies by plugin | no |
The three rows do different jobs. Most setups that think they have upstream checking are running row one and assuming they bought row two.
What max_fails and fail_timeout actually do
They define a sliding window. max_fails is how many failed attempts inside fail_timeout mark a server unavailable, and fail_timeout is also how long that server stays out of rotation afterwards. One parameter, two meanings, which trips people up constantly.
upstream api_servers {
server 10.0.1.11:8080 max_fails=3 fail_timeout=15s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=15s;
}
server {
location / {
proxy_pass http://api_servers;
proxy_next_upstream error timeout http_502 http_503;
proxy_connect_timeout 2s;
}
}
The defaults are max_fails=1 and fail_timeout=10s, documented on the upstream module page. A single timeout removes a backend for ten seconds. That is aggressive on a flaky network and we prefer three failures over fifteen seconds for stateless HTTP backends.
proxy_next_upstream is the line people forget. By default a 502 from your application is a normal response, not a failure, so it never counts toward max_fails. Adding http_502 http_503 changes that. Leave it out and your counter stays at zero while every request errors.
Giving nginx its own health endpoint
This is the other question, and it has a three-line answer. Add a location that returns a fixed body without touching any upstream, and keep it out of the access log.
server {
location = /nginx-health {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
}
That endpoint proves one thing: a worker process accepted the connection and served a response. It says nothing about upstreams, disk, or whether your application is alive. Treat it as a liveness signal and nothing more.
For connection counters rather than a yes or no, stub_status from ngxhttpstubstatusmodule exposes active connections, accepts, handled and requests. Restrict it by IP. It is a scrape target, not a public route.
The gap between accepts and handled is the number worth watching. When those two diverge, nginx is dropping connections because it hit worker_connections, and no status code on any other endpoint will tell you that. A rising active count with flat requests points the same way.
Keep the two endpoints separate. The plain 200 route is what orchestrators and external probes call, so it must stay cheap and always answerable. The counters route is for a metrics scraper on an internal network, and it can afford to be slower.
How do you run an nginx health check in Docker?
Point the image's HEALTHCHECK instruction at the location above, using a client the image already has. The nginx official image ships no curl, so either install one or use the busybox wget that is present in the Alpine variants.
services:
web:
image: nginx:1.27-alpine
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1/nginx-health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
The container runtime then restarts or deregisters the container on repeated failure. We tested this pattern against a deliberately broken config reload and the check stayed green, because nginx keeps serving with the old configuration when a reload fails. A container-level probe cannot catch that. The wider set of trade-offs here is covered in our write-up on container health checks.
When the licence is worth paying for
Active checks earn their money on a quiet service. Passive checking needs failing requests to learn anything, so a backend that died at 03:00 on a low-traffic API stays in rotation until somebody's request pays for the discovery. Active probes remove it before that.
With NGINX Plus the upstream needs a shared memory zone, and match blocks let you assert on the status code, a header or the body. The admin guide has the full syntax. F5's own comparison of active and passive checks is a fair summary of the trade.
If the budget is not there, lyokha/nginx-healthcheck-plugin and the OpenResty Lua ecosystem both add active probing to the open source build. Both mean compiling a module. Weigh that against pointing an external check at each backend directly, which is often simpler. The same reasoning applies to any proxy, as our guide to load balancer health checks works through.
What an nginx health check can never see
Every option above runs inside the box. They all share one blind spot: they report what nginx believes about its backends, and nothing about whether a user reaches nginx.
An expired certificate on port 443, a security group change, a DNS record pointing at a decommissioned address, a floating IP that failed over to a node with stale config. In every one of those the upstream block is perfectly healthy and the site is unreachable.
Only a check from outside the network catches that, and it has to run from more than one place. A routing fault often hits one region and spares another, so a single vantage point turns a partial outage into a coin flip.
An http monitor against the public hostname closes the gap, confirmed from a second region before anyone is paged. Probes run from six regions as of September 2026, and every egress address is listed on the probe IPs page if you allowlist at the edge. The proxy-level version of this argument is worked through in our HAProxy configuration guide.
Common mistakes
Assuming health_check works because it parsed. On open source nginx the directive is unknown and the config fails to load, so people quietly delete it and forget they never replaced it. Check for max_fails on the server lines instead.
Leaving proxy_next_upstream at its default. Application-level 5xx responses do not count as failures unless you list them. The passive counter then stays at zero through an entire outage.
Pointing the self-check at /. A cached homepage survives a dead database. Give the check its own location with no cache, and if it must test a dependency, let it query that dependency rather than a static string.
A working nginx health check is really two configurations doing unrelated jobs: passive upstream removal tuned with max_fails, and a dedicated location that proves the worker is answering. Add an outside probe on the public hostname and the frontend failures both of them miss stop being invisible. The free plan covers ten monitors as of September 2026, which is enough for a proxy pair and the hostnames in front of them.
FAQ
There isn't one. Nginx ships no health route out of the box, so you add a location that returns a fixed 200 response with logging disabled. The common convention is /nginx-health or /healthz, but the path is entirely yours. That endpoint proves a worker answered; it says nothing about upstream servers.
No. The health_check directive belongs to ngx_http_upstream_hc_module, which ships only in NGINX Plus. Open source builds get passive checking through max_fails and fail_timeout on the upstream server lines, plus whatever a third-party module such as the nginx-healthcheck-plugin adds if you compile one in.
Set max_fails and fail_timeout on each server line inside the upstream block, and list the failure conditions in proxy_next_upstream so application 5xx responses actually count. Nginx then removes a server after that many failures and retries it once fail_timeout elapses. Nothing is probed until real traffic arrives.
Use the HEALTHCHECK instruction or the compose healthcheck key, pointing at your own 200-returning location on 127.0.0.1. The official nginx image has no curl, so the Alpine variants usually call busybox wget instead. Set a short interval, a timeout below it, and a start_period so boot does not fail the first probes.
Related reading
- HAProxy Health Check Config: Examples and Gotchas - An HAProxy health check decides which backends stay in rotation. Here are the directives that matter, sane defaults, and the failure it cannot see.
- Load Balancer Health Check: Settings That Matter - A load balancer health check decides which backends get traffic. Here are the four parameters, the shallow-versus-deep trap, and what to return.
- Docker Health Check: Syntax, Compose, and Gotchas - A docker health check tells the daemon whether your container can still serve. Here is the syntax, the Compose ordering trick, and what it cannot see.