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:
- Exact match:
location = /health - Longest prefix match is remembered:
location /api/ - …but regex locations (
location ~ .php$) are checked in file order and win over prefix matches ^~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_connhelps with uneven request costs.ip_hash/hash ... consistentgive 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_formatincluding$request_time,$upstream_response_time,$upstream_addr, and$upstream_cache_status— the four fields that answer "is it NGINX or the backend?" stub_statusgives basic connection metrics; exporters (or the New Relic NGINX integration) turn logs + status into dashboards and alerts alongside the rest of your stack.nginx -tbefore every reload,nginx -s reloadfor 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.