Nginx Upstreams and Load Balancing
The upstream block defines a pool of backend servers. proxy_pass http://<upstream-name> routes to that pool. Nginx handles the algorithm, health, and connection reuse.
Basic upstream
upstream app {
server 10.0.0.1:8000;
server 10.0.0.2:8000;
server 10.0.0.3:8000;
}
server {
location / {
proxy_pass http://app;
}
}
Default algorithm: round robin.
Algorithms
| Directive | Algorithm |
|---|---|
| (none) | round robin |
least_conn; |
least active connections |
ip_hash; |
hash of client IP — same client always to same backend (basic stickiness) |
hash $key [consistent]; |
hash of any variable; consistent makes it consistent-hash (minimum reshuffle on backend changes) |
random [two]; |
random; random two picks 2 randomly and chooses based on the next algorithm (least_conn by default) |
upstream app {
least_conn;
server 10.0.0.1:8000;
server 10.0.0.2:8000;
}
upstream cache_pool {
hash $request_uri consistent; # cache locality — same URI to same backend
server 10.0.0.10:8000;
server 10.0.0.11:8000;
server 10.0.0.12:8000;
}
For most Python apps with similar request costs: round robin or least_conn. least_conn wins when request durations vary a lot.
Per-server options
upstream app {
server 10.0.0.1:8000 weight=3; # 3x as many requests
server 10.0.0.2:8000 weight=1;
server 10.0.0.3:8000 backup; # only used when others are down
server 10.0.0.4:8000 down; # explicitly disabled
server 10.0.0.5:8000 max_fails=3 fail_timeout=30s; # health
server 10.0.0.6:8000 max_conns=100; # cap concurrent connections
}
| Option | Meaning |
|---|---|
weight=N |
proportional traffic share (default 1) |
max_fails=N |
mark down after N consecutive failures within fail_timeout |
fail_timeout=T |
window for fail counting AND duration of “marked down” |
max_conns=N |
hard cap on simultaneous connections to this server |
backup |
only receive traffic when no primary servers are available |
down |
server is permanently unavailable (config-driven maintenance) |
How health checks work (open source nginx)
Open-source nginx does passive health checks only:
- A request fails (timeout / 5xx / connect refused) → counted as a fail.
- After
max_failsconsecutive fails, the server is marked down forfail_timeout. - After
fail_timeout, nginx tries again; success = back in rotation; fail = down for anotherfail_timeout.
There’s no out-of-band “ping the health endpoint” probe in open-source nginx. For active health checks you need:
- NGINX Plus (commercial):
health_check uri=/healthz interval=5s;directive. - Third-party module:
nginx_upstream_check_module. - External tool: cron job that calls each backend and re-renders nginx config + reload.
- Service mesh / cloud LB: skip nginx health checks, use Envoy / ALB / HAProxy in front.
What counts as a fail
proxy_next_upstream controls which upstream errors trigger fail counting and request retry:
proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_next_upstream_timeout 10s;
Defaults:
error timeout(always retry on these).- 5xx and
invalid_headerare NOT default — opt in.
Don’t add non_idempotent unless you want POST/PUT to be retried (which can cause duplicate operations — be careful).
Connection keepalive to upstreams
By default, nginx opens a fresh TCP connection to the upstream for each request. For high-RPS workloads this is wasteful — every request pays a TCP handshake.
upstream app {
server 10.0.0.1:8000;
server 10.0.0.2:8000;
keepalive 32; # max idle connections per worker per upstream
keepalive_requests 1000; # max requests on one connection before re-opening
keepalive_timeout 60s; # idle timeout
}
server {
location / {
proxy_pass http://app;
proxy_http_version 1.1; # required for keepalive
proxy_set_header Connection ""; # MUST clear; default is "close"
}
}
Without proxy_http_version 1.1 and Connection "", keepalive won’t engage. This is a common “I added keepalive but it’s not helping” bug.
For Python upstreams (Gunicorn, uWSGI), keepalive can dramatically reduce per-request overhead at high RPS.
Connection limits per backend
upstream app {
zone app 64k;
server 10.0.0.1:8000 max_conns=50;
}
zone enables shared memory across workers (so the connection count is global, not per-worker). Without zone, max_conns is per-worker — easy to exceed without realizing.
Multiple upstreams for routing
upstream api { server 10.0.0.10:8000; }
upstream web { server 10.0.0.20:8080; }
upstream admin { server 10.0.0.30:8000; }
server {
location /api/ { proxy_pass http://api; }
location /admin/ { proxy_pass http://admin; }
location / { proxy_pass http://web; }
}
Each pool can have its own algorithm, weights, keepalive settings.
DNS resolution gotcha
If server lists a hostname (not IP), nginx resolves it at startup or reload. Hostname changes (e.g. cloud autoscaling, container restarts) won’t be picked up:
upstream app {
server app.internal:8000; # resolved once at start
}
For dynamic DNS:
resolver 10.0.0.2 valid=30s; # nginx's DNS resolver, with 30s cache TTL
server {
location / {
set $upstream "app.internal";
proxy_pass http://$upstream:8000;
}
}
Using a $variable in proxy_pass triggers nginx to re-resolve based on the resolver’s TTL. Catch: this disables the URI-replacement behavior; combine with rewrite if needed (see 05_proxy_pass_trailing_slash.md).
NGINX Plus has resolve parameter on server that handles this without the variable trick.
stream block — TCP/UDP load balancing
For non-HTTP services (Postgres, Redis, raw TCP):
stream {
upstream pg_pool {
server 10.0.0.50:5432;
server 10.0.0.51:5432;
}
server {
listen 6432;
proxy_pass pg_pool;
proxy_timeout 30s;
}
}
L4 load balancing — nginx distributes connections, not requests. A long-lived database connection sits on one backend forever. For even spread under that pattern, use connection pooling on the app side (pgbouncer) and short-lived TCP connections.
See ../../28_networking/10_load_balancers.md for L4 vs L7 trade-offs.
Common interview confusions
- “Open-source nginx does active health checks.” — only NGINX Plus / third-party modules / external tools. OSS does passive only (failed requests count toward
max_fails). - “Adding
keepaliveto upstream is enough.” — also needproxy_http_version 1.1andproxy_set_header Connection "". Otherwise keepalive doesn’t engage. - “Hostnames in
serverre-resolve as DNS changes.” — only at startup/reload. Useresolver+$variableinproxy_pass, or NGINX Plusresolveparameter. - “
ip_hashis real session affinity.” — basic. Doesn’t survive backend changes, doesn’t handle clients behind NAT (all routed to the same backend). For real stickiness usehash $cookie_session consistent.
Interview angle
- “Walk me through nginx’s load balancing options.” — round robin (default), least_conn (best for variable request durations), ip_hash / hash (stickiness), random / random two. Per-server: weight, max_fails, fail_timeout, backup, max_conns.
- “How does nginx do health checks?” — open source: passive only — count failures, mark down after
max_fails, retry afterfail_timeout. Active health checks need NGINX Plus or external tooling. - “Why isn’t keepalive to my upstream working even though I configured it?” — missing
proxy_http_version 1.1andproxy_set_header Connection "". Keepalive requires HTTP/1.1 and clearing the defaultConnection: closeheader. - “How does
proxy_next_upstreamwork?” — controls which conditions (error, timeout, http_500, etc.) cause nginx to retry on the next upstream and count towardmax_fails. Be careful adding non-idempotent retries — POST/PUT can duplicate. - “My backend’s IP changed but nginx still sends to the old one — why?” — hostnames are resolved at startup/reload. Use
resolverand a$variableinproxy_passto re-resolve at request time. - “How would you do consistent hashing in nginx?” —
hash $key consistent;in the upstream block. Useful for cache locality (same key always to same backend; minimal reshuffle when backend pool changes).