Nginx, Properly Explained
Nginx is what happened when a Russian engineer named Igor Sysoev got tired of Apache spawning a process per connection and rewrote the model from scratch using event-driven I/O. The first public release was October 2004. Today nginx serves more of the top 1M websites than any other server, runs as the reverse proxy in front of Kubernetes ingress, and is the substrate underneath OpenResty, Tengine, and Cloudflare's own edge. The configuration language was designed in 2002 to be readable; in 2026 it's most known for the small set of operator-trapping ambiguities that never got fixed.
Nginx is famously hard to write correctly the first time because the configuration language has the surface syntax of a familiar key value; config but the semantics of a small declarative DSL with rules you don't know until you've been bitten. This post is a tour of the bites.
The architecture, briefly
Apache's traditional model: one process (or thread) per connection. A request comes in, you dedicate a worker to it for the lifetime of the request, including any blocking I/O. At 10,000 concurrent connections you have 10,000 workers and your kernel scheduler is the bottleneck.
Nginx's model: a small number of worker processes (typically one per CPU core), each running an event loop. Each worker handles thousands of connections concurrently by multiplexing on epoll (Linux) / kqueue (BSD) / IOCP (Windows). When a connection has nothing to do β waiting on the network, waiting on disk β the worker switches to another connection instead of blocking.
Practical consequence: nginx scales to many more connections than Apache for the same RAM, and a single-core box can saturate a gigabit link doing static-file serving. The cost is that all blocking work has to be either offloaded to a worker pool (aio threads) or pushed downstream to an upstream service. A misconfigured auth_request to a slow backend can starve the entire worker, making nginx behave worse than it should.
The structure of a config file
Nginx config is hierarchical. The top-level (the "main" context) defines workers and global settings. Inside, the events context configures the event loop. Inside http, you define server blocks (one per virtual host), and inside each server, location blocks (one per URL prefix or pattern).
events { ... }
http {
upstream api { ... }
server {
listen 443 ssl;
server_name example.com;
location / { ... }
location /api/ { proxy_pass http://api/; }
}
}
Directives inherit from outer to inner unless overridden. Some directives only work in some contexts. The error message when you put a directive in the wrong context is uniformly bad ("unknown directive"); the docs are the only authority on which contexts each directive supports.
Location matching: the rule that catches everyone
This is the most-misunderstood part of nginx, and the one most production configs get subtly wrong.
A server block can have many location blocks. When a request arrives, nginx picks exactly one location to handle it, by these rules, in order:
- Exact match with
=modifier wins immediately.location = /healthz { ... } - Prefix match with
^~modifier β if the longest such prefix matches, that wins (skipping regex matching).location ^~ /static/ { ... } - Regex match (
~case-sensitive,~*case-insensitive). The first regex that matches wins, in the order they appear in the config.location ~* \.(jpg|png|gif)$ { ... } - Plain prefix match (no modifier). If no regex matched, the longest plain prefix wins.
Three failure modes:
- Two prefix locations, longest wins.
location /api/v1/usersbeatslocation /api/v1/. Easy to forget when adding a new route. - Regex order matters. First regex match wins, not most specific. Reordering regex blocks can change behavior. Hold the discipline of "regex blocks at the bottom, in priority order."
^~skips regex entirely. If you havelocation ^~ /static/andlocation ~ \.html$, a request to/static/page.htmlgoes to the static handler, not the HTML handler. Often desired; sometimes surprising.
The diagnostic command for "which location handled my request?" is to add add_header X-Loc "matched-here" always; to each candidate, hit the URL, inspect headers. Saves hours.
proxy_pass and the trailing slash that ruins your day
location /api/ {
proxy_pass http://backend/; # one slash
}
location /api/ {
proxy_pass http://backend; # no slash
}
These are not the same. A request to /api/users:
- With
http://backend/(trailing slash): nginx strips the matched prefix/api/and forwards/usersto the backend. - With
http://backend(no trailing slash): nginx forwards the full original URI/api/usersto the backend.
People have spent half-days debugging this. The pattern that almost always does what you want for path mounting:
location /api/ {
proxy_pass http://backend/; # both have trailing slash β URI rewriting
}
If you need to preserve the path verbatim (typical for transparent reverse proxies):
location /api/ {
proxy_pass http://backend; # no trailing slash β pass-through
}
A regex location can't use the rewriting form β proxy_pass with a URI part is rejected when the location is a regex. In that case use rewrite to construct the upstream path explicitly.
alias vs root: pick the right one
Both directives map a URL to a filesystem path. They behave differently.
location /static/ {
root /var/www;
}
# Request /static/style.css β /var/www/static/style.css
location /static/ {
alias /var/www/assets/;
}
# Request /static/style.css β /var/www/assets/style.css
root appends the URL to the configured path. alias replaces the matched prefix with the configured path.
The trap is forgetting the trailing slash on alias. Without it, paths concatenate weirdly:
location /static/ {
alias /var/www/assets; # no trailing slash
}
# Request /static/style.css β /var/www/assetsstyle.css β broken
Rule: when using alias with a directory location, the location ends with / and the alias path ends with /. Mismatch is the bug.
The wider rule: prefer root over alias unless you specifically need path replacement. root has fewer surprises.
try_files: serve a static file or fall back
A common pattern for SPAs:
location / {
try_files $uri $uri/ /index.html;
}
try_files checks each argument in order, and serves the first one that exists. Final argument is internal β used as the fallback URI without re-running location matching for the original request, which prevents loops.
For a Phoenix-style app where /api/* goes to a backend and everything else is a static SPA:
server {
root /var/www/spa;
location /api/ {
proxy_pass http://api/;
}
location / {
try_files $uri $uri/ /index.html;
}
}
This is the canonical "static frontend + proxied API" config. The order of the location blocks doesn't matter for prefix matches; nginx picks the longest.
Forwarded headers: the things you must set
When nginx proxies to a backend, the backend by default sees the nginx IP, not the client's. Without intervention:
- The backend's logs show all requests coming from
127.0.0.1. - Geo-IP lookups break.
- Rate-limiting on the backend breaks.
Standard reverse-proxy header set:
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_set_header X-Forwarded-Host $host;
The backend then has to trust these headers. That trust must be conditional on the request coming from a known proxy β otherwise an attacker outside your network sends X-Forwarded-For: 127.0.0.1 and gets treated as localhost.
The newer Forwarded header (RFC 7239) is the spec-blessed alternative: Forwarded: for=192.0.2.43;proto=https;host=example.com. It's better-defined but less universally honored; many backends still expect X-Forwarded-*.
Websockets: the upgrade dance
A naive proxy_pass config drops the WebSocket upgrade. To proxy WebSockets:
location /ws/ {
proxy_pass http://backend/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
The proxy_http_version 1.1 is mandatory β HTTP/1.0 doesn't have the upgrade mechanism. The Connection: upgrade header tells nginx not to set its default Connection: close.
Long-lived WebSocket connections also need proxy_read_timeout raised, otherwise nginx will kill idle connections at its default 60 seconds.
TLS: the configuration that actually matters
The minimum modern config (assumes Let's Encrypt or equivalent):
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Notes:
- Disable TLS 1.0 and 1.1. Both have known issues; major browsers dropped them in 2020. Modern nginx defaults to 1.2+ but check.
http2 onreplaces the olderlisten 443 ssl http2form (deprecated since nginx 1.25). If you can run HTTP/3, addlisten 443 quic reuseportandadd_header Alt-Svc 'h3=":443"; ma=86400';.- HSTS in production only. A misconfigured staging site with HSTS pinned to a bad cert is a multi-day recovery.
ssl_prefer_server_ciphers offis the modern recommendation β let the client pick from the server's allowed list. The opposite was correct in the BEAST/CRIME era; not anymore.- Cipher list: Mozilla's SSL Config Generator gives current best-practice cipher strings. Update them every couple of years.
The HTTP-to-HTTPS redirect:
server {
listen 80;
listen [::]:80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
Use return 301, not rewrite. rewrite is more expensive and is for path manipulation, not full-URL redirects.
Compression: gzip and brotli
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_vary on;
gzip_comp_level 6is the practical sweet spot. Higher levels compress slightly better but cost CPU.gzip_typesspecifies which content-types to compress.text/htmlis on by default and isn't in the list. Missing types from the list (e.g., new font formats, modern image formats) ship uncompressed.gzip_vary onsetsVary: Accept-Encoding, which tells caches that the response varies by encoding.- Don't gzip already-compressed assets (jpg, png, mp4, woff2). Compression on already-compressed data is a net negative.
- Brotli (
ngx_brotlimodule) compresses 15β25% better than gzip for HTML/CSS/JS at similar speed. Worth using if your nginx build includes it; most modern packages don't by default.
Rate limiting: the two-bucket system
Nginx has two rate-limit mechanisms:
limit_reqβ request-rate limiting based on a leaky bucket. Caps RPS per key (typically client IP).limit_connβ concurrent-connection limiting. Caps how many simultaneous connections per key.
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn:10m;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_conn conn 10;
proxy_pass http://backend/;
}
}
The trap: $remote_addr behind a CDN like Cloudflare is the CDN's IP, not the client's, so rate limits collapse all users into one. Use $http_cf_connecting_ip (Cloudflare-specific) or set set_real_ip_from and real_ip_header X-Forwarded-For.
Common pitfalls
- Wrong location-match modifier. Test with explicit
add_headerdebugging. - Trailing slash on
proxy_passURI. Two configs that look the same do different things. - Trailing slash on
alias. Match the location's trailing slash. - Headers stripped on proxy. Set
proxy_set_header Host $host;explicitly. - HTTPS redirect using
rewriteinstead ofreturn. server_namemismatch with the requestHostheader sends the request to the defaultserverblock. Misconfigured certificates often manifest as "the wrong site is showing up."ifdirective abuse. Nginx'sifis famously weird insidelocationblocks; the official wiki page is titled "If is Evil." Usetry_files,map, orerror_pagedirectives where possible.- Forgetting
client_max_body_size. Default is 1 MB. File uploads larger than that get a 413 Payload Too Large. - Default
keepalive_timeoutof 75s holding too many idle connections under load. Tune to your traffic pattern. - Reloading config without checking syntax.
nginx -tfirst, always. A bad reload kills the running server. - Forgetting to
nginx -s reloadafter editing config. The running process keeps the old config in memory. access_log offsuppressing logs you'll want during incidents. Use a separate log format with reduced verbosity instead of disabling.
Operational hygiene
- Validate every config change with
nginx -tbefore reload. - Reload (don't restart) after a successful test:
nginx -s reload. Reload is graceful. - Log file format: include
$request_time,$upstream_response_time,$statusfor performance tuning. - Rotate logs with
logrotateandnginx -s reopen(orUSR1signal). Otherwise log files balloon. - Run nginx behind something else if you need TLS 1.3 0-RTT, advanced HTTP/3, or active health checks β Caddy and HAProxy each do specific things better. For most workloads, nginx is the right default.
When to use nginx vs alternatives
- nginx for: TLS termination, reverse proxy, static-file serving, generic HTTP routing. The default choice; stable, fast, well-documented.
- Caddy for: low-config-overhead deployments. Automatic Let's Encrypt, modern HTTP/3 support, simpler config language.
- HAProxy for: TCP-level load balancing, advanced health checks, financial-grade availability. More observability than nginx for L4/L7 LB.
- Envoy for: service-mesh sidecar, gRPC-native routing, dynamic config from a control plane (Istio, Consul Connect).
- Traefik for: containerized environments where the proxy reads service definitions from Docker / Kubernetes labels.
The nginx config language has aged. Modern alternatives like Caddy require half the lines for the same setup. But nginx's installed base is enormous, the documentation is thorough, and the surprising behaviors above are stable surprises β once you know them, they don't shift.
When you write a new nginx config in 2026, the discipline that saves time: pick a generator (the tool on this site, or a known-good template), customize incrementally, and test every change with nginx -t before reload. The footguns aren't going to be fixed; the path forward is just knowing where they are.
Generate a clean nginx config
The nginx tool on this site composes a working server block from your choices β TLS, reverse proxy, gzip, security headers, rate limits, caching β with the gotchas (HTTP-to-HTTPS redirect, websocket upgrade, proxy headers) wired correctly. Useful when starting a new site or auditing an old config. Generates locally; nothing leaves your browser.
Open the nginx toolRelated guides
Keep the session useful with adjacent reading instead of exiting after one article.
QR Codes, Properly Explained
How QR codes actually work β finder patterns, Reed-Solomon error correction, static vs. dynamic redirects, and the real reasons codes fail in print.
Base64, Properly Explained
A 1989 hack for smuggling binary through 7-bit email transports β and why we still use it for JWTs, data URIs, and a hundred other places. Two alphabets, one common decode failure, and the things it categorically isn't.
URL Encoding, Properly Explained
Why %20 and + both mean space, why encodeURI and encodeURIComponent are not interchangeable, and how the HTML form spec quietly invented its own incompatible variant. RFC 3986 vs application/x-www-form-urlencoded.