nginx exposes seven numbers through stub_status. Six of them are traffic counters that mostly tell you how busy you are. The seventh — the gap between connections accepted and connections handled — is the only one that tells you nginx is turning traffic away, and it is the one nobody graphs.

This guide covers what those numbers mean, the failure each one predicts, and the questions stub_status genuinely cannot answer so you know when to stop asking it.

What nginx is, briefly

nginx was written by Igor Sysoev and released in 2004. It is one of the most deployed web servers on the internet — Atlassian, LinkedIn, Apple and Bleemeo among its users — maintained by F5. Its event-driven architecture is why it serves far more concurrent connections per unit of memory than a process-per-connection design like the historic Apache httpd prefork model.

That architecture is also why its monitoring surface is so small: nginx does not keep per-request state around to be queried.

The metrics that matter

Accepted versus handled: the only real alarm

stub_status reports three cumulative counters on one line: connections accepted, connections handled, and requests served.

In normal operation accepted and handled are identical. They diverge for exactly one reason: nginx reached worker_connections and closed connections it had already accepted without serving them. No error is returned to the client in a way you will see aggregated, and nothing obvious appears in the error log at the volume you would notice.

So the metric to watch is the difference, or better its rate of change. A counter that has been three apart since a spike last March is history; a difference growing right now is active traffic loss.

The fix is worker_connections, and the arithmetic has a trap. Your ceiling is worker_processes × worker_connections, but a reverse proxy consumes two connections per client — one from the browser, one to the upstream. Configure 1024 connections across 4 workers and you can serve roughly 2048 proxied clients, not 4096. Raising it also requires worker_rlimit_nofile to be at least as high, or you trade one ceiling for another and get too many open files in the error log instead.

Requests per connection

Divide requests by handled connections. This is how many requests the average client got out of one TCP connection — your keepalive effectiveness.

A value near 1 means every request is paying for a fresh connection: a TCP handshake and, over HTTPS, a TLS handshake. Usually it means keepalive_timeout is too short, or something in front of nginx is not reusing connections. For upstreams specifically, an upstream block without a keepalive directive opens a new connection to the backend for every single request, which is a surprisingly common and completely invisible tax.

Active, reading, writing, waiting

stub_status breaks active connections into three states:

  • reading — nginx is receiving the request headers.
  • writing — nginx is sending a response, which for a proxy includes waiting on the upstream.
  • waiting — idle keepalive connections, holding a slot and doing nothing.

The useful reading is proportional. A large and growing writing count on a reverse proxy means requests are piling up against a slow backend — nginx is fine, your application is not. A large waiting count is normally healthy (keepalive working) but it consumes connection slots, so it belongs in the worker_connections calculation.

A growing reading count is rarer and more interesting: clients that opened a connection and are sending headers slowly. That is either terrible mobile networks or a slowloris-shaped problem, and client_header_timeout is the lever.

What stub_status cannot tell you

This matters more than any of the above, because it defines where monitoring nginx by its status module stops:

  • No status codes. You cannot see a 5xx rate. A backend returning 502 to every request produces a perfectly healthy stub_status, because nginx handled every connection and served every request — with an error body.
  • No latency. No request duration, no percentiles, nothing.
  • No upstream health. Which backend is slow, which is down, how often nginx failed over — none of it.
  • No per-vhost breakdown. One set of counters for the whole server.

All of that lives in the access log. $status, $request_time and $upstream_response_time are the three log variables worth having in your format if they are not already, and the difference between the last two is time spent talking to a slow client rather than waiting on your application.

Collecting it with Bleemeo

After installing nginx from your distribution’s packages and installing the Bleemeo agent, the agent auto-discovers nginx and starts port checks for service availability.

How the Bleemeo agent discovers nginx and reads its status module

For metrics it reads nginx’s stub status module, which you enable on the default virtual host — /etc/nginx/sites-enabled/default on Debian and Ubuntu:

location /nginx_status {
    stub_status on;
}

Like any nginx location, restrict it. Status endpoints reachable from the internet leak your traffic volumes and, on some setups, more:

location /nginx_status {
    stub_status on;
    allow 127.0.0.1;
    allow 172.16.0.0/16;
    deny all;
}

Note that this endpoint has to be on the default virtual host: with server_name-based routing, a request the agent makes to 127.0.0.1 lands there, not on whichever vhost you happened to add it to. More configuration detail is in the documentation.

The service dashboard you get for free

Once the agent can read the status endpoint, a service dashboard appears:

The nginx service dashboard created automatically by Bleemeo

Building the dashboard you actually want

A custom dashboard for the rest — and usefully for business metrics alongside them, so a drop in checkouts sits next to the traffic that might explain it. The example graphs:

  • Status of nginx
  • Number of requests per second
  • Number of client connections established per second
  • Number of client connections processed per second

Those last two are the accepted and handled rates. Put them on the same graph: the moment they separate is visible instantly, and separately they look identical.

A custom Bleemeo dashboard with nginx metrics

The complete list of available metrics is in the documentation, and you can add anything else Bleemeo collects, including metrics from other hosts:

  • CPU
  • Memory
  • Number of packets sent per second
A Bleemeo dashboard correlating nginx traffic with system metrics

Correlating HTTP traffic with system metrics is what separates “nginx got slow” from “the host started swapping at 14:03 and nginx got slow”.

Which of these deserve an alert

Alert on Why
Accepted minus handled increasing nginx is dropping connections, silently
nginx process or port down The obvious one, and still the most common
writing connections climbing Requests queuing against a slow upstream
Requests per connection near 1 Keepalive is not working; handshake cost on every request
5xx rate from the access log stub_status will never show you this

Requests per second is a dashboard line. It is your traffic, not your health, and a quiet night looks exactly like a broken load balancer.

Nginx monitoring FAQ

What does it mean when nginx accepted more connections than it handled?

nginx hit its connection ceiling — worker_processes × worker_connections — and closed connections it had already accepted without serving them. It is the one stub_status number that reports actual traffic loss, and it does so almost silently otherwise. Raise worker_connections, and raise worker_rlimit_nofile with it or you will hit the file-descriptor limit instead.

How many worker_connections do I need?

More than you would think for a reverse proxy: each client costs two connections, one inbound and one to the upstream. So 4 workers × 1024 connections serves roughly 2,048 proxied clients, not 4,096. Idle keepalive connections count against the same ceiling, which is why the waiting figure belongs in the calculation.

Can I get HTTP status codes or response times from stub_status?

No. stub_status exposes connection and request counters and nothing else — no status codes, no latency, no per-upstream or per-vhost breakdown. A backend returning 502 to every request produces a completely healthy status page. Those signals come from the access log, where $status, $request_time and $upstream_response_time are the variables worth logging.

Why must the status location be on the default virtual host?

Because the agent requests it over 127.0.0.1 without a matching Host header, so nginx routes it to the default server. Adding the location to a named vhost leaves the agent getting a 404 from the default one — a common reason metrics never appear despite the configuration looking right.