Back to Articles
DevOps & Automation

NGINX Deep Dive: Reverse Proxy, Load Balancing, and TLS Done Right

The L7 workhorse — proxying, upstreams, caching, TLS hardening, and the config patterns that scale

Alex Lux2026-06-104 min read
NGINXReverse ProxyLoad BalancingTLSWeb InfrastructureDevOps
NGINX Deep Dive: Reverse Proxy, Load Balancing, and TLS Done Right

NGINX Deep Dive: Reverse Proxy, Load Balancing, and TLS Done Right

If the OSI model is your map, NGINX is where you spend your Layer 7 life (with a useful side gig at Layer 4). It terminates TLS, routes requests, balances load, caches responses, and absorbs abuse — all from a config language terse enough to read in one sitting. It's also one of the most commonly misconfigured pieces of infrastructure on the internet.

This is the guide I wish I'd had: the architecture, the request-routing rules that trip everyone, and hardened configs you can lift.

Why NGINX Is Fast: The Event Model

Apache's classic model dedicated a process/thread per connection. NGINX instead runs a few worker processes (one per CPU core), each handling thousands of connections via an event loop (epoll/kqueue). Connections waiting on slow clients cost almost nothing — which is exactly the job of an edge proxy: sip from thousands of slow client connections, speak fast over a few keepalive connections to your backends.

worker_processes auto;
events {
    worker_connections 4096;
}

The corollary: never block a worker. Anything slow (app logic, disk-heavy work) belongs behind proxy_pass, not inside NGINX.

Reverse Proxy: The Core Pattern

upstream app {
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
    keepalive 32;                     # reuse backend connections
}

server {
    listen 443 ssl;
    http2  on;
    server_name app.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_read_timeout 60s;
    }
}

Every line of the header block matters: without Host your virtual-hosted backend serves the wrong site; without X-Forwarded-For/-Proto your app logs the proxy's IP and generates http:// redirect loops behind TLS. The keepalive + Connection "" pair is the most commonly missed performance win — without it, every request opens a fresh TCP connection to the backend.

Location Matching: The Rules Everyone Gets Wrong

Order of evaluation is not top-down for regexes:

  1. Exact match: location = /health
  2. Longest prefix match is remembered: location /api/
  3. …but regex locations (location ~ .php$) are checked in file order and win over prefix matches
  4. ^~ on a prefix says "if I'm the longest prefix, skip regex checking"

And the trailing-slash trap: proxy_pass http://app; (no URI) passes the original path through; proxy_pass http://app/; (with URI) replaces the matched prefix. One character, completely different routing.

Load Balancing

upstream api {
    least_conn;                        # or: ip_hash; hash $arg_token consistent;
    server 10.0.1.10:3000 weight=3;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000 backup;
    server 10.0.1.13:3000 max_fails=3 fail_timeout=30s;
}
  • round-robin (default) is right until proven otherwise; least_conn helps with uneven request costs.
  • ip_hash / hash ... consistent give session affinity when the app demands it (fix the app instead when you can).
  • Open-source NGINX health checks are passive (max_fails) — a backend is only marked down after real requests fail. Active health checks are an NGINX Plus feature; in OSS-land, pair passive checks with an external monitor, or put readiness logic in your orchestrator.

NGINX also proxies at Layer 4 with the stream module — raw TCP/UDP balancing for databases, syslog, MQTT — handy when you need one edge for both HTTP and non-HTTP listeners.

TLS Configuration That Passes an Audit

server {
    listen 443 ssl;
    http2 on;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/privkey.pem;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;      # modern clients pick better than we do
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;

    ssl_stapling on;
    ssl_stapling_verify on;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;
}

server {                                # HTTP exists only to redirect
    listen 80;
    server_name app.example.com;
    return 301 https://$host$request_uri;
}

TLS 1.2+1.3 only, session tickets off, HSTS on. Test against SSL Labs or testssl.sh after every change — config that loads is not config that's right. Use certbot/ACME for issuance and rotation; hand-managed certs expire on holidays, always.

Caching and Rate Limiting: The Shock Absorbers

Micro-caching even for 1 second collapses thundering herds on dynamic content:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=micro:10m max_size=1g inactive=10m;

location / {
    proxy_cache micro;
    proxy_cache_valid 200 1s;
    proxy_cache_use_stale error timeout updating;   # serve stale while backend struggles
    proxy_cache_lock on;                             # one fill request, not a stampede
    add_header X-Cache-Status $upstream_cache_status;
    proxy_pass http://app;
}

Rate limiting is two lines and saves you at 3am:

limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;

location /api/ {
    limit_req zone=perip burst=20 nodelay;
    proxy_pass http://api;
}

Observability

  • Structured access logs: define a JSON log_format including $request_time, $upstream_response_time, $upstream_addr, and $upstream_cache_status — the four fields that answer "is it NGINX or the backend?"
  • stub_status gives basic connection metrics; exporters (or the New Relic NGINX integration) turn logs + status into dashboards and alerts alongside the rest of your stack.
  • nginx -t before every reload, nginx -s reload for zero-downtime config changes, and keep configs in git — NGINX config drift is unforced downtime.
log_format json_analytics escape=json
  '{"time":"$time_iso8601","status":$status,"rt":$request_time,'
  '"urt":"$upstream_response_time","upstream":"$upstream_addr",'
  '"cache":"$upstream_cache_status","uri":"$request_uri","host":"$host"}';

The Shape of a Good Deployment

Small config files under conf.d/ per site, shared snippets for TLS and proxy headers (include snippets/proxy-headers.conf;), secrets and certs outside the repo, CI that runs nginx -t in a container before anything merges. NGINX rewards the same discipline as everything else in this stack: version control, small diffs, and a test before the reload.

Related Reading