What Nginx is for
Nginx started as an answer to the C10K problem — serving ten thousand concurrent connections on one machine — and became the default edge component of the web. In a typical stack it plays four roles at once:
- Reverse proxy — one public entry point in front of many app processes.
- Load balancer — distribute traffic, detect dead upstreams.
- TLS terminator — do the handshake once at the edge, speak plain HTTP inside.
- Static file server and cache — serve assets and cached responses without touching the app.
It also absorbs slow clients, buffers request and response bodies, enforces rate limits, rewrites URLs, compresses output and emits your first line of access logs.
The event-driven architecture
Apache's classic model gives every connection a thread or process. Nginx instead runs a small fixed set of single-threaded worker processes — usually one per CPU core — each running an event loop over thousands of connections with epoll/kqueue.
- Memory per connection is kilobytes, not megabytes — idle keep-alive connections are cheap.
- No per-request context switching, so throughput stays flat as concurrency climbs.
- The master process enables zero-downtime reloads:
nginx -s reloadstarts new workers and lets old ones drain. - The trap: any blocking operation stalls a whole worker. Keep disk I/O async (
aio,sendfile) and never run synchronous application logic inside Nginx.
How config resolves a request
- Match the listening socket and the
Hostheader againstserver_nameto pick a server block (falls back todefault_server). - Pick a location block: exact
=wins, then^~prefix, then regex~/~*in file order, then the longest plain prefix. - Run the request through phases: rewrite → access/auth → try_files → content handler → filters (gzip, headers) → log.
server {
listen 443 ssl http2;
server_name app.example.com;
root /var/www/app;
location = /healthz { return 200 "ok\n"; }
location ^~ /static/ { expires 1y; add_header Cache-Control "public, immutable"; }
location ~* \.(png|jpg|svg)$ { expires 30d; }
location / {
try_files $uri $uri/ @app;
}
location @app {
proxy_pass http://app_upstream;
}
}^~ prefix matches beat regexes regardless of order. Most "why is this location never hit?" bugs are this rule.Reverse proxy essentials
proxy_pass http://app_upstream; 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 5s; proxy_send_timeout 60s; proxy_read_timeout 60s; proxy_http_version 1.1; # keep-alive to upstream proxy_set_header Connection "";
- Without
X-Forwarded-*, your app sees every client as the proxy — breaking IP logging, geo logic and rate limits. - A trailing slash in
proxy_passchanges path handling:proxy_pass http://api/strips the location prefix, without it the prefix is preserved. - For WebSockets and SSE you must upgrade explicitly and disable buffering, or the stream stalls.
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # map: "" -> "", websocket -> upgrade proxy_buffering off; proxy_read_timeout 3600s;
Load balancing and health
| Method | Directive | Use when |
|---|---|---|
| Round robin | (default) | Uniform, stateless backends |
| Least connections | least_conn | Variable request durations |
| IP hash | ip_hash | Sticky sessions without shared state |
| Consistent hash | hash $key consistent | Cache affinity that survives node changes |
| Weighted | server ... weight=3 | Heterogeneous hardware, canary rollouts |
upstream app_upstream {
least_conn;
keepalive 64; # reuse upstream connections
server 10.0.1.10:8080 max_fails=3 fail_timeout=15s;
server 10.0.1.11:8080 max_fails=3 fail_timeout=15s;
server 10.0.1.12:8080 backup;
}
# retry the next upstream on transient failure
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;Open-source Nginx does passive health checks only: an upstream is marked down after max_fails real failures inside fail_timeout. Active probing needs Nginx Plus or a sidecar. Also be careful retrying non-idempotent requests — a retried POST can double-charge.
Caching
proxy_cache_path /var/cache/nginx keys_zone=app:100m max_size=10g inactive=60m;
location /api/public/ {
proxy_cache app;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 5m;
proxy_cache_valid 404 30s;
proxy_cache_lock on; # single-flight on cache miss
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
add_header X-Cache-Status $upstream_cache_status;
}proxy_cache_lockstops a stampede: one request refills, the rest wait.proxy_cache_use_staleis your outage cushion — serve slightly old data rather than a 502.- Include anything that varies the response in the cache key (auth state, locale, device class), or you will leak one user's page to another.
TLS termination
ssl_certificate /etc/letsencrypt/live/app/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_stapling on; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Terminating TLS at the edge means one handshake cost, one certificate to rotate, and cheap plain HTTP inside the trust boundary. If the internal network is not trusted, re-encrypt with proxy_pass https:// and verify the upstream certificate.
Rate limiting and protection
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conns:10m;
location /api/ {
limit_req zone=api burst=20 nodelay; # leaky bucket with a burst allowance
limit_conn conns 20;
client_max_body_size 10m;
client_body_timeout 10s;
}limit_req is a leaky bucket: rate is the drain rate, burst the queue depth, nodelay lets the burst through immediately instead of trickling. Behind a CDN, rate-limit on the real client IP from X-Forwarded-For via real_ip_header — otherwise you are limiting the CDN.
Debugging 502, 504 and 413
| Symptom | Meaning | Usual cause |
|---|---|---|
| 502 Bad Gateway | Upstream refused or closed the connection | App crashed, wrong port, socket permissions, upstream keep-alive mismatch |
| 504 Gateway Timeout | Upstream too slow | proxy_read_timeout shorter than the request; a slow query |
| 413 Payload Too Large | Body exceeds limit | client_max_body_size (default 1m) too small for uploads |
| 499 | Client disconnected first | User navigated away, or your own timeout is shorter than Nginx's |
| Random 502s under load | Upstream connection churn | Missing keepalive in upstream, or worker_connections exhausted |
Put $upstream_addr $upstream_status $upstream_response_time $request_time in your log format. The gap between request_time and upstream_response_time tells you instantly whether the app or the network/client is slow.
Tuning checklist
worker_processes auto;and raiseworker_connections(1024 is low) plus the OS file-descriptor limit.- Enable
gzip(or Brotli) for text types; never compress already-compressed media. - Long
expiresplusimmutablefor fingerprinted assets; no-store for HTML. - Keep-alive to upstreams — it removes a TCP handshake from every request.
- Always
nginx -tbeforenginx -s reload; a bad config on reload can drop the site. - Hide the version (
server_tokens off;) and set the standard security headers.
Interview framing
"Nginx is our edge: it terminates TLS, serves static assets, and reverse-proxies to app pods with least-connections balancing, upstream keep-alive and passive health checks. Its event-driven worker model means idle and slow connections cost almost nothing, so it absorbs slow clients that would otherwise tie up application workers. Public GETs are cached with cache-lock for single-flight refills and stale-on-error so an upstream blip degrades instead of failing. We rate limit per real client IP with a leaky bucket, and our access logs carry upstream timing so we can separate app latency from proxy or client latency in one query."