Archtin
All articles
InfrastructureNetworkingSystem Design13 min read

Nginx Explained: Reverse Proxy, Load Balancing, TLS and Tuning

How Nginx works — the event-driven worker model, request processing phases, reverse proxying and upstreams, load-balancing algorithms, caching, TLS termination, rate limiting, and the config mistakes that cause 502s and 504s.

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.

1master (root, reads config, binds ports)2worker × N cores3event loop410k+ connections each
  • 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 reload starts 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

  1. Match the listening socket and the Host header against server_name to pick a server block (falls back to default_server).
  2. Pick a location block: exact = wins, then ^~ prefix, then regex ~ / ~* in file order, then the longest plain prefix.
  3. 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;
  }
}
A realistic server block.
Location precedence is not top-to-bottom
Exact and ^~ 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_pass changes 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;
WebSocket / SSE passthrough.

Load balancing and health

MethodDirectiveUse when
Round robin(default)Uniform, stateless backends
Least connectionsleast_connVariable request durations
IP haship_hashSticky sessions without shared state
Consistent hashhash $key consistentCache affinity that survives node changes
Weightedserver ... weight=3Heterogeneous 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_lock stops a stampede: one request refills, the rest wait.
  • proxy_cache_use_stale is 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

SymptomMeaningUsual cause
502 Bad GatewayUpstream refused or closed the connectionApp crashed, wrong port, socket permissions, upstream keep-alive mismatch
504 Gateway TimeoutUpstream too slowproxy_read_timeout shorter than the request; a slow query
413 Payload Too LargeBody exceeds limitclient_max_body_size (default 1m) too small for uploads
499Client disconnected firstUser navigated away, or your own timeout is shorter than Nginx's
Random 502s under loadUpstream connection churnMissing 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 raise worker_connections (1024 is low) plus the OS file-descriptor limit.
  • Enable gzip (or Brotli) for text types; never compress already-compressed media.
  • Long expires plus immutable for fingerprinted assets; no-store for HTML.
  • Keep-alive to upstreams — it removes a TCP handshake from every request.
  • Always nginx -t before nginx -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."

Keep reading

Suggested next articles based on this one.

Design it, don't just read it.

Practise LLD and system design problems with structured rubrics and AI feedback.

Start practising free