Load balancing and upstreams
Upstream blocks, round-robin, least_conn and ip_hash, weights, backup servers, passive and active health checks, and draining a node without dropping requests.
Upstream blocks and algorithms
upstream app {
least_conn; # send to the node with the fewest active requests
server 10.0.0.11:8080 weight=3;
server 10.0.0.12:8080;
server 10.0.0.13:8080 max_fails=2 fail_timeout=10s;
server 10.0.0.14:8080 backup; # only used when the others are unavailable
keepalive 32; # pooled connections to the upstreams
}
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_next_upstream error timeout http_502 http_503;
}
}| Method | Distribution | Use when |
|---|---|---|
| round-robin | Even turn taking | Backends are similar |
least_conn | Fewest active connections | Request durations vary widely |
ip_hash | Same client to same node | Stateful sessions, last resort |
hash $key consistent | Sticky by a chosen key | Cache locality |
random two | Random of two candidates | Large fleets |
proxy_next_upstream retries on another node, but only for requests nginx knows are safe to repeat. A non-idempotent POST is not retried by default, and configuring it to be is a decision about duplicate writes, not a performance tweak.
Health checks
# passive: nginx observes real traffic and ejects a node after failures
upstream app {
server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
}
# active: nginx actively probes every node, available in nginx Plus
upstream api {
zone api 64k;
server 10.0.0.11:8080;
server 10.0.0.12:8080;
health_check interval=5s fails=2 passes=2 uri=/health match=healthy;
}
match healthy {
status 200;
body ~ "ok";
}- Passive checks need
proxy_next_upstreamto be useful; without it a failed node produces errors instead of traffic moving elsewhere. - After
fail_timeoutnginx tries the node again with a single request. A node that is still down counts as failing again, so recovery costs only one request in 30 seconds. proxy_connect_timeoutshould be short — a few seconds. Long connect timeouts turn a dead node into slow responses rather than fast failures.- Health endpoints should check downstream dependencies, not just return 200 unconditionally.
Deploying and draining safely
# drain: keep serving existing requests but send new ones elsewhere.
# Mark the node down and reload; in-flight requests are not interrupted by a reload.
upstream app {
server 10.0.0.11:8080 down;
server 10.0.0.12:8080;
}
# then, after the connections have drained, deploy that node
# nginx -s reload -> graceful handover, old workers finish their requests⚠️
A graceful reload does not wait for long-lived connections. A websocket or a slow streaming response keeps an old worker alive until its keepalive expires, so add
worker_shutdown_timeout and drain upstream nodes before reloading during a deployment.FAQ
Is ip_hash a good way to keep sessions sticky?
It works but it distributes unevenly behind a shared NAT, and losing one node loses its sessions. Prefer stateless tokens or a shared session store; use ip_hash only as a stopgap.
How do I add a new node without disruption?
Add it to the upstream with the others, reload, and verify traffic with the access log or upstream response-time variables before considering the change complete.
Related
TLS hardening and certificates with Let's Encrypt Performance tuning: workers, buffers and timeouts
Last refreshed 2026-09-18.