</>
Back to Blog
Dev 2026-05-23 11 min read

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.

NginxC10KReverse ProxyTLSHTTP/2Caching

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:

  1. Exact match with = modifier wins immediately.
    location = /healthz { ... }
    
  2. Prefix match with ^~ modifier β€” if the longest such prefix matches, that wins (skipping regex matching).
    location ^~ /static/ { ... }
    
  3. Regex match (~ case-sensitive, ~* case-insensitive). The first regex that matches wins, in the order they appear in the config.
    location ~* \.(jpg|png|gif)$ { ... }
    
  4. 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/users beats location /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 have location ^~ /static/ and location ~ \.html$, a request to /static/page.html goes 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 /users to the backend.
  • With http://backend (no trailing slash): nginx forwards the full original URI /api/users to 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 on replaces the older listen 443 ssl http2 form (deprecated since nginx 1.25). If you can run HTTP/3, add listen 443 quic reuseport and add_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 off is 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 6 is the practical sweet spot. Higher levels compress slightly better but cost CPU.
  • gzip_types specifies which content-types to compress. text/html is 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 on sets Vary: 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_brotli module) 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_header debugging.
  • Trailing slash on proxy_pass URI. 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 rewrite instead of return.
  • server_name mismatch with the request Host header sends the request to the default server block. Misconfigured certificates often manifest as "the wrong site is showing up."
  • if directive abuse. Nginx's if is famously weird inside location blocks; the official wiki page is titled "If is Evil." Use try_files, map, or error_page directives where possible.
  • Forgetting client_max_body_size. Default is 1 MB. File uploads larger than that get a 413 Payload Too Large.
  • Default keepalive_timeout of 75s holding too many idle connections under load. Tune to your traffic pattern.
  • Reloading config without checking syntax. nginx -t first, always. A bad reload kills the running server.
  • Forgetting to nginx -s reload after editing config. The running process keeps the old config in memory.
  • access_log off suppressing 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 -t before reload.
  • Reload (don't restart) after a successful test: nginx -s reload. Reload is graceful.
  • Log file format: include $request_time, $upstream_response_time, $status for performance tuning.
  • Rotate logs with logrotate and nginx -s reopen (or USR1 signal). 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 tool

Related guides

Keep the session useful with adjacent reading instead of exiting after one article.

View all guides

Cookie Consent

We use cookies to enhance your experience and show relevant ads. You can customize your preferences.