deep·tech·intuition
intermediate ·

Nginx Deep Intuition

An experienced engineer's guide to Nginx

1. One-Sentence Essence

Nginx is an event-driven, non-blocking reverse proxy that happens to be configured as a web server, load balancer, or cache depending on which directives you write.

That order matters. Most tutorials lead with “nginx is a web server.” That framing actively misleads you. The web server is one product of a more general machine: a tiny number of single-threaded processes, each running an event loop, each capable of holding tens of thousands of connections open simultaneously without breaking a sweat. Everything else — virtual hosts, SSL termination, rate limiting, caching, gRPC proxying, WebSockets — is a configuration choice layered on top of that core.

Once you internalize that nginx is fundamentally a programmable network event loop, every weird behavior you’ll encounter starts to make sense: why if is dangerous, why reloads are seamless, why the config language looks declarative but sometimes acts imperative, why a misconfigured proxy_pass silently truncates URLs. The web-server framing hides all of this. The event-loop-with-a-config-language framing reveals it.


2. The Problem It Solved

In the late 1990s and early 2000s, the dominant web server was Apache, and Apache had a problem with a memorable name: the C10K problem — how do you serve ten thousand concurrent clients on one machine? Apache’s classic answer was one process (or thread) per connection. Each connection got its own stack, its own scheduler slot, its own memory. With 10,000 clients and a few megabytes per process, you ran out of RAM long before you ran out of CPU. With thousands of threads, the kernel spent more time context-switching than serving requests.

Igor Sysoev was running rambler.ru, one of Russia’s largest portals, and he was watching Apache fall over under exactly this kind of load. Static content — images, CSS, JavaScript — was the worst offender. Each of those tiny requests still cost a full process. He wrote nginx (released publicly in 2004) around a different idea, borrowed from high-performance network programming: don’t dedicate a process to a connection — dedicate a process to a CPU core, and let that process juggle thousands of connections via OS-level event notification primitives (epoll on Linux, kqueue on BSD).

The result was a server that could hold a hundred thousand concurrent connections on hardware where Apache choked at a few thousand, while using a tiny fraction of the RAM. That single architectural choice is why nginx exists, and it’s why nginx looks the way it does today. Every design decision in nginx — the small fixed pool of workers, the single-threaded worker loop, the master/worker split, the way config reloads work, the existence of proxy_buffering as a default — descends from that one bet on event-driven I/O.

The bet paid off. Nginx now sits in front of more of the internet’s busiest sites than any other web server, and the C10K problem is so thoroughly solved that people now talk about C10M (ten million connections). The original problem is dead. The architecture that killed it is what you’re learning here.


3. The Concepts You Need

Before anything else, you need the vocabulary. These concepts thread through everything that follows.

Processes and concurrency

  • Master process — A single privileged process. It reads the config, binds to ports (which requires root for ports below 1024), forks worker processes, and manages their lifecycle. It does not handle traffic itself. Its job is supervision.
  • Worker process — The processes that actually serve traffic. Each is single-threaded. You typically run one worker per CPU core. All workers are clones of each other and listen on the same sockets; the kernel distributes incoming connections among them.
  • Event loop — The core mechanism inside each worker. The worker tells the kernel “wake me when any of these N sockets has data,” sleeps, gets woken up, processes whatever’s ready, and goes back to sleep. The same worker can hold tens of thousands of idle connections because idle connections cost almost nothing.
  • epoll / kqueue — The OS-level mechanisms that make the event loop fast. epoll (Linux) and kqueue (BSD/macOS) let one thread monitor thousands of file descriptors at near-constant cost. Older mechanisms like select and poll scaled linearly with the number of connections; epoll doesn’t.
  • Non-blocking I/O — When a worker reads or writes a socket, it never blocks waiting for data. If there’s nothing there yet, the read returns immediately, the worker moves on to another connection, and the kernel will notify it when data becomes available.

Configuration structure

  • Directive — A single configuration instruction (e.g., listen 80;, proxy_pass http://backend;). Some are simple (single-line); some open blocks with { ... }.
  • Context — A block in the config that scopes a group of directives. The major contexts, from outside to inside: main (the top of the file), events (worker connection settings), http (everything HTTP), server (a virtual host), location (a URL path within a server). There’s also stream (for raw TCP/UDP proxying) and upstream (for defining backend pools).
  • Inheritance — Directives in outer contexts are inherited by inner contexts, unless the inner context overrides them. Set gzip on; in http and every server and location inherits it. Override it in a location and only that location changes.
  • Server block — A virtual host. Selected by the combination of listen (which IP/port) and server_name (which hostnames). One nginx process can serve thousands of distinct domains.
  • Location block — A URL-path-level handler inside a server. Where most routing logic lives.

Request handling

  • Phase — Nginx processes each request through a fixed sequence of phases (post-read, server rewrite, find-config, rewrite, post-rewrite, preaccess, access, post-access, try-files, content, log). You usually don’t need to know the names, but you need to know that phases exist — because that’s why rewrite and if and proxy_pass interact in non-obvious ways.
  • Upstream — A named pool of backend servers that nginx proxies to. Defined with the upstream directive. Referenced by name from proxy_pass.
  • Reverse proxy — Nginx accepting requests from clients and forwarding them to backend servers. The clients think nginx is the server; the backends think nginx is the client. Contrast with a forward proxy, which clients use to access external sites (corporate web filters, VPNs).
  • SSL/TLS termination — Nginx decrypts HTTPS at the edge and forwards plain HTTP to the backend. The backend never sees the encryption. This is the dominant production pattern.
  • Buffering — Nginx reads the full response from the backend into a buffer (memory, then disk if large) before sending it to the client. This frees the backend from holding a connection open for the slow client. Default-on, and one of the biggest reasons nginx is fast in front of slow application servers.
  • Keepalive — Reusing a TCP connection for multiple HTTP requests instead of opening a new one each time. Critical to performance. Nginx supports it both client-facing and upstream-facing, with separate settings.

State and persistence

  • Shared memory zone — A chunk of memory shared across all workers. Used for things that need cross-worker state: rate limiting counters, cache metadata, SSL session caches. Defined with *_zone directives.
  • Cache — Nginx can cache upstream responses to disk, keyed by a hash of the request. The metadata lives in a shared memory zone; the response bodies live on disk.
  • Variable — Runtime values like $host, $remote_addr, $request_uri, $scheme. Available in most directives. Some are read-only (set by nginx); some you can set yourself.

Keep these concepts within reach. The rest of this document assumes them.


4. The Distilled Introduction

This section is the 10-hour-tutorial replacement. We’ll go from zero to a working production-shaped nginx in about 3,000 words, and you’ll learn what’s actually happening at every step.

Installing

On Debian/Ubuntu: apt install nginx. On RHEL/Rocky/Fedora: dnf install nginx. On macOS for local dev: brew install nginx. The distro packages are usually a release or two behind; for production, install from nginx’s own repo (instructions at nginx.org). Use the stable branch in production (currently 1.28.x, with critical fixes only); use mainline (1.29.x) if you want new features and tolerate occasional behavior changes.

After install, nginx is typically managed by systemd: systemctl start nginx, systemctl reload nginx, systemctl status nginx. The package puts configs in /etc/nginx/, logs in /var/log/nginx/, and the default document root somewhere like /var/www/html or /usr/share/nginx/html.

The config file layout

The main file is /etc/nginx/nginx.conf. Modern distro packaging splits it up: the main file has the top-level events and http blocks, then include /etc/nginx/conf.d/*.conf; pulls in per-site configs. Many Debian-family setups also use sites-available/ and sites-enabled/ (you put real config in sites-available/ and symlink the active ones into sites-enabled/).

A minimal but realistic nginx.conf looks like:

# Main context (no enclosing braces)
user www-data;
worker_processes auto;          # one worker per CPU core
pid /run/nginx.pid;

events {
    worker_connections 1024;    # max connections per worker
    use epoll;                  # on Linux; auto-detected
}

http {
    # MIME types and defaults
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # Logging
    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';
    access_log /var/log/nginx/access.log main;
    error_log  /var/log/nginx/error.log warn;

    # Performance
    sendfile        on;
    tcp_nopush      on;
    keepalive_timeout 65;
    gzip            on;

    # Pull in per-site configs
    include /etc/nginx/conf.d/*.conf;
}

That http block holds all HTTP-related config — every server, every cache zone, every upstream, every shared rate-limit. The events block is small but mandatory; it configures the worker’s event loop.

Your first server: serving static files

Drop this into /etc/nginx/conf.d/example.conf:

server {
    listen 80;
    server_name example.com www.example.com;
    root /var/www/example;

    location / {
        try_files $uri $uri/ =404;
    }

    location /images/ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
}

Run nginx -t (test the config — always run this before reloading), then systemctl reload nginx. What you’ve just told nginx:

  • Listen on port 80.
  • Match requests where the Host: header is example.com or www.example.com.
  • For files, look under /var/www/example.
  • For any request matching /, try the literal file $uri, then the directory $uri/, then return 404. ($uri is the request path.)
  • For anything under /images/, set a long browser cache lifetime.

try_files is the workhorse for static file serving. It’s how you implement “serve the file if it exists, otherwise fall back to X.” That fallback is how SPAs work:

location / {
    try_files $uri $uri/ /index.html;
}

Now any URL that isn’t a real file returns /index.html, which your JavaScript router handles.

root vs alias — the one trap

This trips up everyone exactly once. Both directives map URLs to disk paths, but they behave differently:

  • root /var/www/example; in location /images/ means: when a request comes in for /images/cat.jpg, look at /var/www/example/images/cat.jpg. The location prefix is appended to the root.
  • alias /var/www/photos/; in the same location means: strip /images/ from the request URI, then append the remainder to the alias path. So /images/cat.jpg becomes /var/www/photos/cat.jpg.

Rule of thumb: use root when the URL path mirrors the disk path, alias when they diverge. Use alias sparingly — its trailing slash behavior is finicky, and many gotchas trace back to alias misuse.

Reverse proxying to a backend

This is the dominant use case for nginx today. Your app (Node, Python, Go, Java, whatever) listens on a high port; nginx fronts it on port 80/443.

upstream app {
    server 127.0.0.1:3000;
    keepalive 32;
}

server {
    listen 80;
    server_name api.example.com;

    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";        # required for keepalive
        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;
    }
}

Three things to know here. First, upstream defines a named pool — even with one backend, defining an upstream block lets you set keepalive, which keeps idle TCP connections to the backend warm. Without that, every request opens a new TCP connection, which is expensive. Second, you must set proxy_http_version 1.1 and clear the Connection header for keepalive to work — HTTP/1.0 closes connections by default. Third, you almost always want those four proxy_set_header lines: the backend needs to know the original Host, the client’s real IP, and whether the original request was HTTPS.

The proxy_pass trailing slash gotcha

This is the most-asked-about question in every nginx forum:

# Variant A: no URI in proxy_pass
location /api/ {
    proxy_pass http://backend;
}
# Request /api/users → backend sees /api/users (path preserved)

# Variant B: URI in proxy_pass (even just /)
location /api/ {
    proxy_pass http://backend/;
}
# Request /api/users → backend sees /users (matched prefix stripped)

If proxy_pass has only a host (no path, not even /), the full original URI is forwarded. If it has any path component, the matching location prefix gets replaced with that path. Get this wrong and your backend gets paths it doesn’t expect. We’ll come back to this in the Gotchas section because it bites everyone.

Load balancing

Same upstream syntax, just more servers:

upstream app {
    least_conn;
    server 10.0.0.10:3000 weight=3;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000 backup;
    keepalive 64;
}

The four built-in algorithms:

  • Round-robin (default) — Rotate through servers in order. Fine for stateless backends with similar capacity.
  • least_conn — Send to whichever backend has the fewest active connections. Better when request times vary.
  • ip_hash — Hash the client IP to pick a backend. Sticky sessions without cookies. Crude — sessions break when the upstream pool changes.
  • hash $variable [consistent] — Hash any variable (e.g., $request_uri, or a session ID cookie). With consistent, uses ketama hashing so pool changes only shift a fraction of keys. The right way to do session stickiness.

weight=N sends N times as many requests there. backup means “only use this server when all primaries are down.” max_fails=N fail_timeout=Ts are passive health checks: if N requests fail within T seconds, mark the server unavailable for T seconds. Active health checks (a separate probe) are NGINX Plus only in the official build; the open-source way is third-party modules or just trusting passive health checks.

SSL/TLS

In production you terminate TLS at nginx and proxy plaintext to the backend:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    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=63072000" always;

    location / {
        proxy_pass http://app;
        # ... proxy_set_headers as before
    }
}

# Redirect HTTP → HTTPS
server {
    listen 80;
    server_name api.example.com;
    return 301 https://$host$request_uri;
}

A few practical notes: use certbot (apt install certbot python3-certbot-nginx) to get free Let’s Encrypt certs with auto-renewal. ssl_session_cache shared:SSL:10m saves about 40,000 sessions across all workers — significant CPU savings for clients that reconnect. http2 on the listen line gives you HTTP/2 between client and nginx; that’s a one-line throughput and latency win for browsers. HTTP/3 (listen 443 quic reuseport; with extra config) is supported in mainline 1.25+ but still relatively new in production. Don’t bother proxying HTTP/2 to the backend unless you’re proxying gRPC — proxy_http_version 1.1 with keepalive is essentially as fast for normal HTTP traffic.

Caching

Nginx can cache backend responses to disk, which turns dynamic content into static content for repeat requests:

# In http context
proxy_cache_path /var/cache/nginx
    levels=1:2
    keys_zone=app_cache:10m
    max_size=10g
    inactive=60m
    use_temp_path=off;

# In location
location / {
    proxy_pass http://app;
    proxy_cache app_cache;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_valid 200 302 10m;
    proxy_cache_valid 404 1m;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    add_header X-Cache-Status $upstream_cache_status;
}

The keys_zone=app_cache:10m allocates 10 MB of shared memory for cache metadata (keys, expiration times) — about 80,000 keys. The bodies live on disk under /var/cache/nginx. proxy_cache_use_stale is a beautiful directive: it tells nginx to serve a stale cached response if the backend is currently down or slow. That alone has saved more sites during incidents than I can count. The X-Cache-Status header (HIT, MISS, BYPASS, EXPIRED, STALE) is essential for verifying that caching is working.

Rate limiting

# In http context
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

# In location
location /api/ {
    limit_req zone=api burst=20 nodelay;
    proxy_pass http://app;
}

This limits each client IP to 10 requests per second, with a burst of 20 (queued requests that exceed the rate). nodelay means: count bursts but don’t artificially slow them — return 503 only when the burst is exhausted. Use $binary_remote_addr rather than $remote_addr because it’s more memory-efficient. 10 MB of zone memory holds about 160,000 IPs.

WebSockets and other long-lived connections

WebSockets need the Upgrade header passed through:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws/ {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 86400s;        # don't kill idle WS
        proxy_send_timeout 86400s;
    }
}

The map directive is your friend here — and in general. It’s the right tool for “set X based on Y” without resorting to if.

Reloading and operations

The single most useful operational fact: nginx reloads are zero-downtime.

nginx -t              # always test first
systemctl reload nginx
# (or: nginx -s reload)

When the master receives a reload signal, it forks new workers with the new config. New connections go to the new workers; old workers finish serving their existing connections and exit cleanly. Apache people, this is why nginx people don’t have maintenance windows for config changes.

Other signals you might use: nginx -s stop (immediate), nginx -s quit (graceful), nginx -s reopen (close and reopen logs — for log rotation).

Logging

Nginx writes two logs by default: an access log (every request) and an error log (warnings, errors). The access log format is configurable; the default is fine but you’ll usually want to add upstream timing:

log_format detailed '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent" '
                    'rt=$request_time uct=$upstream_connect_time '
                    'uht=$upstream_header_time urt=$upstream_response_time '
                    'cs=$upstream_cache_status';

$request_time is total wall-clock time from first byte received to last byte sent. $upstream_response_time is just the backend’s contribution. The difference between them is essentially what nginx is adding. If $request_time is 5s and $upstream_response_time is 0.05s, you’ve got a slow client, not a slow backend.

That covers the bulk of what nginx does day to day. The rest of the document will deepen this — what’s happening underneath, the patterns experienced people reach for, and the places where this stuff bites you.


5. The Mental Model

Three core ideas. Internalize these and most of nginx becomes predictable.

Core Idea 1: Nginx is a small fixed pool of workers, each running an event loop over thousands of connections.

This is the architectural keystone. Apache’s classic model was: connection arrives → fork a process → process owns that connection → connection ends → process is reused. Each process runs synchronously and blocks on I/O. To serve more clients, you need more processes.

Nginx flips this. You start a fixed, small number of worker processes — typically one per CPU core, so 4 to 32 workers on most servers. Each worker is single-threaded. Each worker holds a list of every connection it owns: maybe 5,000, maybe 50,000. Each iteration of the event loop, the worker asks the kernel “which of these is ready for I/O?” via epoll, gets back a small list (often just a few), processes those events without blocking, and loops back. A connection that’s idle (waiting for the client to send the next byte) costs nothing but a file descriptor and a small struct in memory.

What this predicts:

  • Adding workers beyond CPU count doesn’t help. Each worker is single-threaded and CPU-pegged when busy; more than one per core just costs context-switching.
  • A slow backend can starve a worker only of CPU, not of capacity. While one connection waits on the backend, the worker handles 4,999 others. This is why nginx in front of a slow backend doesn’t itself slow down — the slowness is just visible in $upstream_response_time.
  • Anything that blocks the event loop is a disaster. This is why nginx is so conservative about third-party features. A blocking read() of a file from disk would freeze every connection that worker owns. Nginx uses sendfile(), async I/O threads (aio threads), and never executes arbitrary user code in the worker (with one exception: Lua via OpenResty, which is also non-blocking).
  • A worker crash is contained. If one worker dies, the master forks a replacement. The connections owned by that worker are dropped, but the other workers keep humming. This is why nginx is famously stable.
  • Workers don’t share memory by default. Each is its own process address space. Anything that needs to be shared (rate limit counters, SSL session caches, cache key metadata) lives in explicit shared memory zones. That’s why every directive that involves cross-worker state has a *_zone parameter.

Core Idea 2: The config is a declarative tree, but request processing runs through fixed phases.

The config looks like static data: server blocks, location blocks, directives. And mostly, that’s the right mental model — you write down rules, nginx applies them.

But every request flows through a fixed sequence of phases: post-read, server-rewrite, find-config, rewrite, post-rewrite, preaccess, access, post-access, try-files, content, log. Each phase has handlers that fire on the request in a defined order. Most directives belong to one specific phase. Some directives (like rewrite and if) live in the rewrite phase, which runs before the content phase (where proxy_pass lives). Some (like access_log) live in the log phase, which runs after the response is sent.

What this predicts:

  • Directives in the same context can execute in non-obvious order. rewrite rules fire before proxy_pass regardless of where they appear in the location block.
  • A rewrite ... last; can change which location handles the request. When last fires, nginx re-runs the find-config phase with the new URI. This is why you can chain rewrites across locations.
  • if is “evil” because it sits in the rewrite phase and creates a hidden nested location. When you put proxy_pass (a content-phase directive) inside an if, you’re crossing phase boundaries in a way the config language doesn’t make explicit. The result is sometimes correct, sometimes a silent disaster.
  • Inheritance is per-directive, not block-level. Each directive has its own inheritance rules — some directives in an outer context are inherited only if the inner context defines none of that directive’s “siblings.” This is why partially overriding proxy_set_header resets the whole list.

You don’t need to memorize the phases. You need to know they exist so that when the config doesn’t do what you expected, you can recognize the symptom: “I think this is declarative, but it’s running through a pipeline.”

Core Idea 3: Nginx is a reverse proxy first. The “web server” is just one configuration of that proxy where the upstream happens to be the local filesystem.

When you write root /var/www/site; and serve static files, nginx is internally doing the same kind of work it does when proxying to a backend: matching the request, transforming the URI, fetching content, buffering it, sending it to the client. The filesystem is just an upstream that happens to be local and fast.

This isn’t pedantic — it changes how you read the config. proxy_buffering, proxy_cache, proxy_read_timeout all exist because nginx was designed around the idea of a fast, well-behaved proxy in front of slow, possibly-misbehaving backends (whether that backend is your Node app or a remote filesystem mount). The whole try_files-then-proxy_pass idiom for SPA hosting is literally “try the local upstream first, fall through to the remote upstream” expressed declaratively.

What this predicts:

  • Most nginx wisdom carries over between web-server and reverse-proxy roles. Caching, buffering, rate limiting, header manipulation — the same directives work whether you’re serving files or proxying to apps.
  • proxy_buffering on (the default) explains a lot of behavior. Nginx reads the entire response from the backend into a buffer before sending it to the client. This is why nginx in front of a slow Python app feels snappy: the app finishes its work and frees its worker as soon as nginx has the bytes, while nginx slowly drips them out to the client.
  • WebSockets, gRPC, Server-Sent Events all need special treatment because they violate the “request goes in, response comes out” assumption. Buffering must be off; timeouts must be long; HTTP/1.1 keepalive must be on. Each is a deviation from the proxy default.
  • The line between “nginx” and “the application” is a contract about who handles what. Static files? Nginx, always — it’s better than your app. SSL? Nginx, almost always. Authentication? Sometimes nginx, more often the app. Routing? Negotiable.

These three ideas — fixed worker pool with event loops, phased declarative-imperative config, reverse-proxy-first architecture — explain about 80% of nginx’s behavior. The rest of the document references them constantly.


6. The Architecture in Plain English

Let’s walk through what actually happens when a request arrives. This is the narrative that ties the mental model to the system.

You run systemctl start nginx. Systemd spawns one process: the master. The master, running as root, reads nginx.conf, validates it, and opens the listen sockets — binding to port 80, port 443, whatever you’ve configured. Binding to those ports requires privilege; this is the master’s main reason to be root.

The master then forks N worker processes, where N is worker_processes (typically one per CPU core, often set to auto). The workers drop privileges to www-data (or whatever user is set to) and inherit the listen sockets from the master. Now you have a master + N workers, all sharing the same set of open sockets but each running independently.

The master’s job from here on is supervisory: handle signals (reload, reopen logs, graceful shutdown), monitor workers, restart any that crash. It does not handle traffic. If you kill -9 the master, the workers keep running until you also kill them.

A client makes a TCP connection to port 80. The kernel’s networking stack accepts the SYN, completes the handshake, and places the new connection in the accept queue of the listen socket. Because all workers share the socket, any of them can accept it. Originally nginx used an “accept mutex” to prevent thundering-herd; modern nginx on modern Linux uses SO_REUSEPORT which gives each worker its own accept queue and the kernel balances connections among them.

One worker — say worker 3 — accepts the connection. The connection now belongs to worker 3 for life. It will not migrate to another worker. Worker 3 adds the connection’s socket to its epoll set and goes back to its event loop. The event loop says: “Tell me when any of my 17,000 sockets has data.”

Eventually the client sends the request bytes: GET /api/users HTTP/1.1, headers, blank line. The kernel marks worker 3’s socket as readable. Next iteration of the event loop, epoll_wait returns with that socket in the ready list. Worker 3 reads the bytes (non-blocking; never waits), feeds them into the HTTP parser state machine, and once headers are complete, starts processing.

Request processing begins. Worker 3 walks the phases:

  • Post-read. Mostly used by special modules; usually a no-op.
  • Server-rewrite. Any rewrite directives at the server level run.
  • Find-config. Match the request URI against the server’s location blocks. The matching algorithm is its own thing (see Gotchas) but the result is: one specific location block is now selected.
  • Rewrite. Any rewrite directives in the matched location run. A rewrite ... last; here would re-run find-config.
  • Access. allow/deny/auth_basic/auth_request directives run. If any denies, the response is set and we jump to log.
  • Content. This is where the actual work happens. If the location has proxy_pass, nginx initiates a connection to the upstream. If it has try_files, nginx checks the filesystem. If it has return, nginx generates the response directly. Only one content handler runs per request.

Let’s say it’s a proxy_pass. Worker 3 looks at the upstream’s connection pool, finds an idle keepalive connection (if keepalive is set), and writes the proxied request. If no idle connection is available, it opens a new TCP connection to the backend — non-blocking, so the worker keeps serving other connections while the TCP handshake happens.

The backend takes its time — maybe 200ms. During that 200ms, worker 3 is happily processing requests from thousands of other clients. The backend’s socket is in worker 3’s epoll set, marked “I’m waiting for data here.”

The backend starts responding. Worker 3 reads bytes from the backend, writes them into a buffer (default: 8KB times 8 buffers, configurable via proxy_buffers). If the response exceeds in-memory buffer space, nginx spools to a temporary file. Once nginx has the full response (with proxy_buffering on), or as soon as bytes start flowing (with buffering off), it begins writing to the client socket.

The client may be on a slow mobile connection. That’s fine. Worker 3 writes what it can; when the client’s socket would block, the worker moves on. Eventually all bytes are delivered. Worker 3 logs the request (the log phase), closes or keeps the client connection (depending on keepalive), and returns to the event loop.

Meanwhile, suppose you push a config change. You run nginx -s reload. The master receives SIGHUP. It re-reads the config, validates it, and if valid, forks a new set of workers using the new config. The new workers start accepting new connections. The master tells the old workers to gracefully shut down. The old workers stop accepting new connections but keep serving their existing ones. Each old worker exits when its last connection closes. No requests dropped. No restart.

Two more bits of state to know about. Shared memory zones are chunks of memory created at startup that all workers can read and write (with appropriate locking). Rate limit counters live here. Cache key metadata lives here. SSL session caches live here. When you configure limit_req_zone ... zone=api:10m;, you’ve reserved 10 MB of shared memory all workers will use to track request rates per IP. Without shared zones, each worker would have its own counter and the limits would effectively be N times what you configured.

Helper processes: alongside workers, you may see a cache loader (runs once at startup, scans the cache directory, populates the keys zone, exits) and a cache manager (runs periodically, evicts expired entries to stay under max_size). These are scheduled conservatively and barely show up in CPU usage.

That’s the whole architecture. A master, a small number of workers, each running an event loop and walking requests through phases, with explicit shared memory for the cross-worker state. Everything else is config layered on top of this skeleton.


7. The Things That Bite You

The non-obvious behaviors. Each connects back to the mental model — they’re not arbitrary bugs, they’re consequences of the design.

Gotcha 1: The proxy_pass trailing slash

What you’d expect: proxy_pass http://backend; and proxy_pass http://backend/; do the same thing. They’re the same URL, just with or without a trailing slash, right?

What actually happens: They behave completely differently. With no URI in proxy_pass, the full original request URI is forwarded. With any URI (even just /), the part of the URI matching the location prefix is stripped and replaced.

location /api/ {
    proxy_pass http://backend;     # /api/users → backend /api/users
}
location /api/ {
    proxy_pass http://backend/;    # /api/users → backend /users
}
location /api/ {
    proxy_pass http://backend/v2;  # /api/users → backend /v2users (!!)
}

Why: This isn’t a bug — nginx treats the path component of proxy_pass as a replacement for the matched location prefix. The presence or absence of a path is the trigger. It’s a documented behavior; it’s just deeply unintuitive.

How to handle: Always include the trailing slash on both location and proxy_pass when you want the prefix stripped: location /api/ { proxy_pass http://backend/; }. When you don’t, leave proxy_pass URI-less: proxy_pass http://backend;. Pick one pattern per service and stick to it. Test with curl and look at backend access logs.

Gotcha 2: if is evil

What you’d expect: if works like an if-statement in any other language. You put conditions in it; if true, the directives inside run.

What actually happens: if creates an implicit nested location. Directives inside if don’t simply “execute conditionally”; they live in a phantom config context. The result: some directives work inside if, some don’t, some appear to work but corrupt request state in subtle ways. The official nginx wiki page is literally titled “If Is Evil.”

# Looks reasonable. Actually broken.
location /api/ {
    if ($request_method = POST) {
        proxy_pass http://backend_post;
    }
    proxy_pass http://backend_default;
}

The two proxy_pass directives are in different (implicit) contexts. Depending on nginx version, headers can leak or get dropped; proxy_set_header directives outside the if may not apply inside it; the wrong upstream can be hit. The bug surface is large and version-dependent.

Why: if is part of the rewrite module, which is imperative. Most other directives are declarative and expect a stable context. The mismatch creates phantom contexts that break inheritance.

How to handle: The only directives that are truly safe inside if: return, rewrite, set, break. Use map for value-based conditionals. Use separate location blocks for routing-based conditionals. If you must use if, keep it tight and only with safe directives:

# Map: the right tool for value-based conditionals
map $http_user_agent $is_mobile {
    default 0;
    "~*mobile" 1;
}

# Separate locations: the right tool for routing
location ~ \.php$ { fastcgi_pass ...; }
location /        { proxy_pass http://app; }

Gotcha 3: Location matching order isn’t config order

What you’d expect: Nginx tries each location block in the order written. First match wins.

What actually happens: The matching algorithm is priority-based, not order-based, with one exception (regex locations).

The order:

  1. Exact match (location = /foo): if any exact match wins, stop. Highest priority.
  2. Longest prefix match. Among all prefix locations (no modifier, or ^~), find the longest one that matches.
  3. If that longest prefix used ^~, stop and use it (this is the “non-regex” prefix).
  4. Otherwise, check regex locations (~, ~*) in config order. First regex match wins.
  5. If no regex matches, fall back to the longest prefix from step 2.
location /images/ { ... }         # A
location ~ \.png$ { ... }         # B
location = /images/logo.png { ... } # C

Request /images/logo.png: hits C (exact match wins). Request /images/photo.png: hits B (regex beats prefix). Request /images/photo.jpg: hits A (no regex matches; longest prefix).

Why: Nginx pre-computes a tree of prefix locations for O(log n) prefix matching, then scans regexes linearly. The priority ordering serves that data structure.

How to handle: Use ^~ when you want a prefix to beat regexes (“treat anything under /static/ as static, no regex shenanigans”). Use = for hot paths to skip the search entirely (location = /health saves a few microseconds per healthcheck). Write the matching order out explicitly when designing a config; don’t rely on file order. Use error_log debug; if you’re truly confused — the debug log shows which location matched.

Gotcha 4: proxy_set_header inheritance resets the list

What you’d expect: proxy_set_header inherits from outer contexts and adds to the list at the inner context.

What actually happens: As soon as you set any proxy_set_header in an inner context, the entire list resets — you lose all the inherited headers.

http {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    server {
        location / {
            proxy_set_header X-Custom-Header "foo";
            # Host, X-Real-IP, X-Forwarded-For are GONE here.
            proxy_pass http://backend;
        }
    }
}

Why: Many nginx directives that take a list reset the list on inner override. This is consistent across proxy_set_header, add_header, gzip_types, and several others. It’s documented in each directive’s page but is easy to miss.

How to handle: Either redefine everything in the inner context, or use include files for the common headers:

# /etc/nginx/proxy_headers.conf
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;

# In your location
location / {
    include /etc/nginx/proxy_headers.conf;
    proxy_set_header X-Custom-Header "foo";
    proxy_pass http://backend;
}

Gotcha 5: Headers with underscores are silently dropped

What you’d expect: HTTP headers with underscores (like X_Custom_Header) get passed through to the backend.

What actually happens: Nginx silently drops them by default. The header never reaches your backend, and you spend hours wondering why your custom auth isn’t working.

Why: Both - and _ in HTTP headers map to _ in CGI variable names (HTTP_X_CUSTOM_HEADER). To prevent header smuggling via name collisions, nginx drops headers with underscores by default.

How to handle: Two options. (1) Use dashes in your custom headers — X-Custom-Header, not X_Custom_Header. This is the right answer. (2) If you can’t change the header name, add underscores_in_headers on; in the server context.

Gotcha 6: Upstream DNS is resolved once at startup

What you’d expect: If you use a hostname in upstream, nginx re-resolves the DNS when the IP changes.

What actually happens: Nginx open-source resolves the hostname once at startup (or reload) and caches the IP forever. If you point upstream at myservice.internal and that DNS record changes, nginx keeps hitting the old IP until you reload.

Why: Resolving DNS in the request path would block the event loop. Nginx is conservative about anything that could block. The mitigations exist but require explicit config.

How to handle: Three options. (1) In NGINX Plus, the resolve parameter on the server directive enables runtime re-resolution. (2) In open-source, use a proxy_pass to a variable and configure resolver:

resolver 10.0.0.2 valid=30s;
location / {
    set $backend "myservice.internal";
    proxy_pass http://$backend;
}

This re-resolves on every request (cached for valid seconds). (3) Reload nginx whenever the backend IP changes. In container environments, services like Consul Template or NGINX Ingress Controller automate this.

Gotcha 7: error_log off doesn’t disable the error log

What you’d expect: error_log off; turns off the error log, just like access_log off; turns off the access log.

What actually happens: Nginx interprets off as a filename. It creates a file called off (literally) in the config directory and writes the error log there. You’ll find it later as /etc/nginx/off and wonder how it got there.

Why: The error_log directive doesn’t have an off mode — only the access log does. The asymmetry is historical.

How to handle: Don’t disable the error log. Period. It’s where every weird symptom shows up first. If you want less noise, set the level: error_log /var/log/nginx/error.log crit; only logs critical and worse.

Gotcha 8: worker_connections is total, not per-direction

What you’d expect: worker_connections 1024; means each worker can handle 1024 client connections.

What actually happens: That 1024 is the total connection budget per worker — including upstream connections, listening sockets, and every other file descriptor. Acting as a reverse proxy, each request typically consumes 2-3 connections: one from the client, one to the upstream, possibly one to write a temp file. So 1024 is really ~340 simultaneous proxied requests per worker.

Why: Connections cost file descriptors. From the worker’s perspective, the kernel doesn’t distinguish “client” from “upstream” — it’s all sockets.

How to handle: Set worker_connections higher than you think (10240 or more is normal). Also set worker_rlimit_nofile to at least 2× worker_connections so the OS allows that many FDs per worker. Then check fs.file-max (system-wide) is bigger than worker_processes * worker_rlimit_nofile.

Gotcha 9: Default client_max_body_size is 1 MB

What you’d expect: File uploads work out of the box if your app handles them.

What actually happens: Anything larger than 1 MB returns 413 Request Entity Too Large. People hit this on the first file upload they test.

Why: Default is conservative to prevent runaway uploads from filling disk.

How to handle: Set it appropriately for your use case in the relevant server or location:

client_max_body_size 100M;     # or 0 for no limit (don't)

Gotcha 10: try_files with $uri and PHP

What you’d expect: This common pattern handles “try the file, else send to PHP”:

location / {
    try_files $uri $uri/ /index.php$is_args$args;
}

What actually happens: Fine, usually. The trap: when combined with a PHP block like location ~ \.php$ { fastcgi_pass ...; } and the wrong PHP setting, a request for /uploads/photo.jpg/foo.php will be interpreted by PHP — and if cgi.fix_pathinfo=1 (a once-common default), PHP executes the .jpg file as a script. Arbitrary code execution.

Why: Two layers of file path guessing — nginx’s location matching combined with PHP’s path-info logic — produce an exploit surface.

How to handle: Set cgi.fix_pathinfo=0 in php.ini. In your nginx config, use try_files $uri =404; inside the PHP location block to ensure the file actually exists before invoking PHP. And keep upload directories out of any executable location.

Each of these gotchas comes back to the mental model. Trailing slashes, location order, and inheritance reflect the declarative tree. if-is-evil reflects the phased pipeline. DNS caching and connection limits reflect the event loop. Headers-with-underscores and error_log off reflect that nginx prioritizes safety and predictability over convenience.


8. The Judgment Calls

What a senior engineer thinks about, not what a feature page tells you.

Call 1: How many workers?

Option A: worker_processes auto; (one per core) Option B: Fewer than cores Option C: More than cores

What experienced engineers do: worker_processes auto; is right 95% of the time, and the remaining 5% you usually want fewer, not more. Workers are CPU-bound when busy; more than one per core just creates context switches. Fewer workers makes sense when nginx shares the box with other processes (e.g., the app it’s proxying to) — then you might want worker_processes 2 on an 8-core machine to leave cores for the app. More workers almost never helps. If you think you need more, the real problem is usually that a blocking operation (DNS, disk I/O, third-party module) is stalling workers — fix that instead.

The signal: Check top during peak load. If your workers are at 100% CPU and you have idle cores, the workers may be limited by something else (e.g., worker_connections). If workers are at low CPU but throughput is bad, the bottleneck isn’t nginx.

Call 2: Buffering on or off?

Option A: proxy_buffering on; (default) — nginx buffers backend response, drips to client Option B: proxy_buffering off; — nginx streams bytes through as it gets them

What experienced engineers do: Leave it on. Buffering is one of nginx’s superpowers — it lets your slow backend finish fast and serve the next request, while nginx handles slow clients. Turn it off only when you have specific streaming needs: long-polling, Server-Sent Events, real-time streaming responses, gRPC. Even then, prefer architectural fixes (use proper SSE/WebSocket protocols) over disabling buffering on a general endpoint.

The signal: If a client is supposed to receive data progressively before the full response is generated (streaming JSON, log tail, AI text-generation tokens), buffering is wrong for that location. Configure it per-location, not globally.

Call 3: SSL termination at the edge or end-to-end TLS?

Option A: Terminate at nginx, plaintext to backend Option B: Terminate at nginx, re-encrypt to backend (proxy_pass https://backend) Option C: Pass-through (TLS not terminated; stream block with SNI)

What experienced engineers do: Option A is the default and the right answer most of the time. Your backend is on a private network or localhost; encrypting localhost traffic is paranoia tax. Option B becomes correct when the backend is on an untrusted network (cross-cloud, cross-datacenter) — but also know it doubles your TLS CPU cost. Option C is for cases where nginx can’t terminate (mTLS that the backend must verify, or you’re routing TCP-level to multiple TLS endpoints by SNI). Pass-through means you can’t do HTTP-level routing or caching for that traffic.

The signal: Trust boundary of the network between nginx and backend. If the network is private and trusted, terminate at the edge. If not, end-to-end. If you can’t terminate (compliance, mTLS), pass through.

Call 4: Where do you put rate limiting?

Option A: Globally in the http context Option B: Per-server (per-domain) Option C: Per-location

What experienced engineers do: Define the limit_req_zone in http (shared memory zones must live in http), but apply limit_req per-location. Different endpoints have different sensitivities — /api/login should be tightly rate-limited (5 req/s) to deter brute-force, /api/feed can be looser (50 req/s) for normal use. A single global limit either over-restricts the legitimate-traffic endpoint or under-restricts the sensitive one.

The signal: Different endpoints have different cost structures (a login that hits the auth backend vs a static asset). Different endpoints have different attack surfaces. Rate-limit accordingly.

Call 5: When do you reach for nginx caching?

Option A: Cache aggressively at nginx Option B: Cache only at the application layer (Redis/Memcached) Option C: Cache at the CDN, not nginx Option D: Don’t cache; rely on backend speed

What experienced engineers do: Use nginx cache when (1) the response is the same for many users (anonymous content), (2) the backend is slow or expensive, and (3) you don’t already have a CDN. If you have a CDN, the CDN typically wins (it’s closer to the user). If your content is per-user, application-level caching is usually better (more granular). Nginx caching shines for anonymous high-traffic pages — a marketing site, an API endpoint with shared data, a status page.

The killer feature: proxy_cache_use_stale lets nginx serve stale cache when the backend is sick. That single line has saved more incidents than most engineering effort.

The signal: If you’re caching per-user, the cache key gets complicated and the hit rate is low — wrong tool. If your CDN already handles the same content, you’re duplicating. If your backend is fast enough, you may not need the complexity.

Call 6: Stream module vs HTTP module?

Option A: Use http for everything you can Option B: Use stream for raw TCP/UDP proxying

What experienced engineers do: Use http whenever the protocol is HTTP — even for things like gRPC (use grpc_pass in http). Use stream when you’re proxying a non-HTTP protocol (PostgreSQL, MySQL, MQTT, SMTP, raw TLS) or when you want to do TCP-level load balancing that doesn’t peek at L7. stream is much more limited (no caching, no header rewriting — it’s just bytes), but it’s the right tool when the protocol isn’t HTTP.

The signal: If you’re proxying anything not built on HTTP, you need stream. If you’re terminating TLS for a non-HTTP protocol, you need stream with ssl.

Call 7: Static files from nginx or from the app?

Option A: Have nginx serve static files directly Option B: Let your app serve everything, with nginx just proxying

What experienced engineers do: Always serve static files from nginx if you can. Nginx serves a file via sendfile() — kernel copies the file straight from the page cache to the socket, never touching userspace. Your app spends an entire request cycle to serve a 50KB CSS file. The difference is two orders of magnitude.

The exception: your “static” files are actually generated, signed, or access-controlled, in which case the app needs to be in the path. Even then, look at X-Accel-Redirect — the app authorizes the request and tells nginx to serve the actual file.

The signal: If the file is a pure asset (CSS, JS, images, fonts, downloads with no access control) and ages well, nginx serves it. If access control or generation is needed, app serves the decision, nginx serves the bytes.

Call 8: Reload vs restart?

Option A: Always reload Option B: Reload most things, restart for some Option C: Restart for everything

What experienced engineers do: Reload (nginx -s reload or systemctl reload nginx) for config changes, period. Reload is zero-downtime, restart is not. The only reasons to restart are: changing the master process binary (binary upgrade — and even that has a graceful path with kill -USR2), or recovering from a worker bug that requires fresh processes. Config-level changes — adding sites, changing upstreams, swapping SSL certs, tweaking timeouts — are all reloads.

The signal: If you find yourself restarting nginx regularly, something is wrong with your config management or your deployment automation. Reload is the supported path.

Call 9: Use the open-source build or NGINX Plus?

Option A: Open-source nginx Option B: NGINX Plus (paid) Option C: Open-source plus OpenResty for Lua scripting

What experienced engineers do: Open-source unless you specifically need Plus features. The Plus-only ones that matter: active health checks (probing backends rather than just observing failed requests), dynamic upstream reconfiguration via API, real-time monitoring dashboard, JWT auth, advanced session persistence. Most teams don’t need these enough to justify per-instance licensing. OpenResty (open-source nginx + LuaJIT + a curated module set) is the right answer when you need scripting — dynamic upstream selection, complex auth flows, custom rate-limiting logic. The trade-off: Lua handlers run on the worker event loop, so badly-written Lua can stall a worker.

The signal: If you’re paying for nginx Plus to get health checks, evaluate HAProxy or Envoy first. If you’re scripting nginx via Lua and the script is non-trivial, you might be reaching for the wrong tool — maybe what you want is a real reverse proxy with API control (Envoy, Traefik).

Call 10: When to walk away from nginx entirely?

Option A: Use nginx Option B: Use Envoy (or Istio’s Envoy) Option C: Use Caddy Option D: Use HAProxy Option E: Use a cloud-managed load balancer (AWS ALB, GCP Load Balancer)

What experienced engineers do: Default to nginx for traditional web traffic and reverse proxying. Choose Envoy when you need dynamic configuration (xDS APIs, service mesh, advanced traffic policy — canaries, mirroring, retries with sophisticated logic). Choose Caddy for small-to-medium deployments where automatic HTTPS is a real operational win. Choose HAProxy when you need extreme TCP load-balancing or your team already knows it deeply. Choose a managed load balancer when you don’t want to run anything yourself and the feature gap is acceptable.

The honest truth: for serving fewer than ~10,000 concurrent connections — which is the vast majority of deployments — the performance differences are noise. Pick on configuration ergonomics, team familiarity, and ecosystem fit.

The signal: Are you in Kubernetes with a service mesh story? You’re probably already using Envoy under the hood (Istio, Linkerd, etc.); embrace it. Are you a small team with mostly static content? Caddy’s auto-HTTPS will save you ops time. Are you scaling beyond what a single nginx instance can handle? You’re already in load-balancer-of-load-balancers territory; talk to your cloud provider. Are you somewhere in between? Nginx is the safe default.


9. The Commands and APIs That Actually Matter

The 20% of nginx you use 80% of the time, organized by what you’re trying to do.

Operations

nginx -t                    # Test config syntax. ALWAYS run before reload.
nginx -T                    # Test AND dump the full merged config to stdout. Invaluable for debugging.
nginx -s reload             # Reload config. Zero-downtime.
nginx -s reopen             # Reopen log files (for log rotation).
nginx -s quit               # Graceful shutdown.
nginx -s stop               # Immediate shutdown.
nginx -v                    # Version.
nginx -V                    # Version + compile-time options + modules. Critical for debugging.

Get in the habit of nginx -t && systemctl reload nginx as a single muscle-memory command. Never reload without testing first.

Server block patterns

# Catch-all default server (protects from Host-header confusion)
server {
    listen 80 default_server;
    listen 443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;       # For TLS: don't even accept the handshake
    return 444;                    # Special nginx code: close without responding
}

# Redirect HTTP → HTTPS for a real domain
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

# Wildcard subdomain
server {
    listen 443 ssl http2;
    server_name *.example.com;
    # ...
}

Location patterns

# Exact match for hot paths
location = /healthz {
    access_log off;
    return 200 "ok\n";
}

# Prefix beat regex
location ^~ /static/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# Regex with capture
location ~ ^/users/(?<user_id>\d+)/profile$ {
    proxy_pass http://backend;
    proxy_set_header X-User-ID $user_id;
}

# Named location (only reachable internally)
location @fallback {
    proxy_pass http://backend;
}
location / {
    try_files $uri $uri/ @fallback;
}

Headers and the proxy contract

The “standard four” you always send to a backend:

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_add_x_forwarded_for appends $remote_addr to any existing X-Forwarded-For header — proper proxy chaining. $host is the client’s Host header (preferred over $http_host because it falls back to $server_name if absent).

Timeouts (the ones you’ll actually tune)

# Client-facing
client_body_timeout       60s;     # Time client has to send body
client_header_timeout     60s;     # Time client has to send headers
keepalive_timeout         65s;     # Idle keepalive timeout (slightly > load balancer's)
send_timeout              60s;     # Time client has to receive a chunk of response

# Upstream-facing
proxy_connect_timeout     5s;      # TCP handshake to backend
proxy_send_timeout        60s;     # Time to send request body to backend
proxy_read_timeout        60s;     # Time between bytes from backend (NOT total)

proxy_read_timeout is between bytes, not total response time. A backend that drips bytes can take forever and not trigger this. Use proxy_next_upstream_timeout for a true upper bound.

Caching

# Setup (http context)
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mc:100m
                 max_size=10g inactive=24h use_temp_path=off;

# Use (location)
proxy_cache mc;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;

proxy_cache_background_update on returns a stale response immediately while refreshing the cache in the background. proxy_cache_lock on ensures only one request hits the backend for a missed key. Together, these turn cache stampedes into smooth behavior.

Rate limiting

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

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

limit_req is for request rate; limit_conn is for concurrent connections. Combined, they handle most abuse cases.

Variables worth knowing

$host                     Request Host header (with fallback)
$remote_addr              Client IP
$request_uri              Full URI with args, unchanged
$uri                      Current URI (changes during rewrites)
$args / $query_string     Query string
$scheme                   http or https
$request_method           GET, POST, etc
$http_<name>              Any request header (e.g., $http_user_agent)
$cookie_<name>            Any cookie
$arg_<name>               Any query arg
$server_name              Matched server_name
$upstream_addr            Upstream that handled the request (post-fact)
$upstream_response_time   Backend processing time
$request_time             Total time from first byte received to last byte sent
$upstream_cache_status    HIT/MISS/etc

Debugging

# Show full merged config
nginx -T

# Tail logs
tail -F /var/log/nginx/error.log /var/log/nginx/access.log

# Increase log verbosity temporarily
error_log /var/log/nginx/error.log debug;   # Then reload. Generates a LOT.

# Check what nginx is doing right now
ss -tnp | grep nginx       # active connections
ps auxf | grep nginx       # process tree (master + workers)

# Status module (if enabled)
location = /nginx_status {
    stub_status on;
    allow 127.0.0.1;
    deny all;
}

stub_status is built-in. It gives you active connections, requests/sec, reading/writing/waiting counts. Scrape it with Prometheus’s nginx exporter for graphing.


10. How It Breaks

Failure modes, organized by symptom — what to look for, what to check first.

502 Bad Gateway

Symptom: Client sees 502. Nginx error log shows things like connect() failed or upstream prematurely closed connection.

Root cause (mental model): Nginx tried to reach the upstream and either couldn’t connect or the connection died before a response. Almost always the backend is the problem, not nginx.

Diagnose:

  1. curl http://upstream-ip:port/ from the nginx box. Can nginx reach the backend at all?
  2. Check the backend’s own logs. Did the backend crash, restart, or close the connection?
  3. Check $upstream_addr and $upstream_response_time in nginx access log. Was nginx trying the right IP? How long was it waiting?
  4. Check firewall/security groups between nginx and backend.

Fix: Almost always at the backend. Nginx-side fixes when the backend is intermittent: increase proxy_next_upstream_tries, add a backup server, lengthen proxy_connect_timeout if connect is genuinely slow (otherwise leave it short to fail fast).

504 Gateway Timeout

Symptom: Client sees 504. Error log shows upstream timed out.

Root cause: Backend took longer than proxy_read_timeout between bytes (or proxy_connect_timeout at connection time).

Diagnose:

  1. Check $upstream_response_time. Is it really hitting the timeout, or is the timeout too short?
  2. Is the backend actually slow, or is it dropping the connection? (Slow → $upstream_response_time close to timeout. Drop → very small value with upstream prematurely closed.)
  3. Is something in between (a network device, a load balancer, the cloud provider) killing long-lived connections?

Fix: If the backend is legitimately slow for this endpoint (a big report, a long search), bump proxy_read_timeout for that location. If it’s a sign of backend distress, fix the backend. Don’t paper over real backend latency with longer timeouts globally.

413 Request Entity Too Large

Symptom: Uploads fail with 413.

Fix: Bump client_max_body_size to accommodate. Default is 1 MB; set it generously for upload endpoints, conservatively elsewhere.

499 (request closed by client)

Symptom: Lots of 499s in access log.

Root cause: Client disconnected before nginx finished responding. Common when the backend is slow and the client times out first.

Diagnose: Correlate with $upstream_response_time. High 499 + high backend time = slow backend, frustrated clients. High 499 + low backend time = something else (mobile clients on flaky networks, eager retry logic, etc.).

Fix: Speed up the backend, or implement the long-running operation as async (kick off a job, return immediately, client polls).

Connection limits reached

Symptom: Errors like worker_connections are not enough or accept() failed (24: Too many open files).

Root cause: Out of file descriptors or out of worker connection slots.

Fix: Increase worker_connections. Increase worker_rlimit_nofile to at least 2× that. Verify fs.file-max is comfortable. If you’re really hitting these on a single nginx, it’s time to scale horizontally.

Config reload failed

Symptom: nginx -t fails, or reload causes a syntax error.

Diagnose: Read the error carefully — it points to the line and usually the directive. Common causes: missing semicolons, mismatched braces, directive in wrong context (e.g., proxy_pass in http context).

Fix: Test on a staging instance first. Use nginx -T to see the merged config (including all includes). Version-control your nginx config and code-review changes.

Worker died and restarted

Symptom: Error log shows worker process ... exited on signal X and a new worker spawned. Connections on the dead worker were dropped.

Root cause: A bug in nginx or a third-party module, an out-of-memory kill, a segfault in OpenResty’s Lua.

Diagnose: Check the signal: signal 9 = OOM kill (check dmesg); signal 11 = segfault (check core dump; might need module debugging). Signal 6 = abort (often a Lua panic).

Fix: If OOM, check memory usage — running with caching enabled but proxy_cache_path keys_zone too small can cause issues. If segfault, third-party modules are the usual suspect; try without them. Open-source nginx itself is exceptionally stable.

The general debugging workflow

When something is wrong and you don’t know what:

  1. nginx -T — dump the merged config. Confirm it’s what you think it is. Includes, conditionals, and inheritance can produce surprises.
  2. tail -F error.log — the error log is the diagnostic gold mine. Everything weird shows up here first.
  3. tail -F access.log with a detailed log_format — check status codes, $upstream_status, $upstream_response_time, $upstream_addr, $upstream_cache_status. The request’s life flashes before your eyes.
  4. curl -v from the nginx box itself — eliminates network and DNS variables. If curl from the nginx host fails, nginx isn’t the problem.
  5. error_log debug; — temporarily. The debug log shows phase-by-phase what nginx is doing for each request, including which location matched, which rewrites fired, which upstream was chosen. Big logs, but answers all routing-related “why”.
  6. ss -tnp | grep nginx — see actual TCP state. Lots of CLOSE_WAIT means nginx is holding connections clients have closed (often a backend issue). Lots of TIME_WAIT is usually fine.

The fastest debuggers don’t guess — they confirm. Each step above either confirms a layer or rules it out.


11. The Downsides

Nginx has real, durable costs. None of these go away with experience or better config. They’re the price of admission.

Downside 1: The config language is genuinely terrible

Where it comes from: The config language grew organically over twenty years. It’s nominally declarative but has imperative escape hatches (if, rewrite, set) with non-obvious semantics. Inheritance rules are per-directive and inconsistent. The trailing slash on proxy_pass changes behavior fundamentally. Multiple ways to do the same thing have subtly different effects.

What it costs you: Every nginx-using team has, somewhere in its history, a 4-hour debugging session that ended in “oh, the if block was creating a hidden context.” Onboarding engineers takes longer than for any comparable tool. The number of nginx configs in production that “work but nobody’s sure why” is uncomfortably high. Errors are often silent — a misconfigured proxy_pass doesn’t fail at reload, it just sends requests to the wrong place.

When this is a dealbreaker: When your team is small enough that no one can specialize in nginx, and your routing is non-trivial. Caddy or a cloud load balancer with a sane web UI may save you weeks per year.

What people think mitigates this but doesn’t: “We’ll just standardize on a base config template.” This works for a while, but the moment a new requirement requires deviating from the template, you discover that nobody understands the template either.

Downside 2: Dynamic reconfiguration is genuinely missing in open-source

Where it comes from: Nginx was designed when service inventories were static. The upstream list is read at config time, hostnames are resolved once, and changes require a reload.

What it costs you: In modern container environments where backends come and go every minute, reloading nginx every time a pod starts or stops is operationally heavy — even though reloads are zero-downtime, they’re not zero-cost (each reload doubles the process count briefly, drops keepalive connections, can spike memory). The workarounds (resolver with variables in proxy_pass, OpenResty for dynamic upstream selection, NGINX Ingress Controller doing template-and-reload) all add complexity. Envoy was designed around dynamic config (xDS protocol) and runs circles around nginx for this use case.

When this is a dealbreaker: Kubernetes with frequent pod churn and need for instant routing updates. Service mesh use cases. Anything where “the source of truth for backend addresses lives somewhere else and changes constantly.”

What people think mitigates this but doesn’t: “We’ll just use a config generator and reload.” For small clusters, fine. For large dynamic ones, you’ll be reloading constantly and have weird behavior at scale. The Kubernetes community has been migrating away from ingress-nginx to Gateway API implementations (often Envoy-based) for exactly this reason.

Downside 3: Observability is mediocre out of the box

Where it comes from: Nginx originated before observability was a first-class concern. The built-in tools — access log, error log, stub_status — are basic. Anything richer requires assembling pieces: VTS module for per-upstream stats, Prometheus exporter for scraping, custom log formats parsed downstream.

What it costs you: You can’t easily answer questions like “what’s the p99 latency of the /api/checkout endpoint?” or “which upstream got the most failed requests in the last hour?” from nginx alone. You have to pipe logs into something else (Loki, Elastic, Datadog) and query there. NGINX Plus has a live activity dashboard built in; the open-source version doesn’t.

When this is a dealbreaker: Highly regulated environments, or teams where the SRE function expects deep, structured signals from every component. Envoy ships rich Prometheus metrics natively.

Downside 4: True active health checks require NGINX Plus

Where it comes from: Passive health checks (mark a server bad after N failures) are open-source. Active health checks (proactively probe servers, mark as bad before any user request fails) are Plus-only.

What it costs you: In open-source nginx, the first user request to a freshly-failed backend gets the error. You’re always one request behind. For low-traffic services, you may not notice. For high-traffic ones, the visible blast radius before mark-as-bad can be significant. Workarounds (custom Lua, third-party modules, external health-checker scripts) exist but each adds operational surface.

When this is a dealbreaker: Latency-sensitive production with strict SLOs. Use HAProxy or a cloud LB if budget can’t accommodate Plus.

Downside 5: Single-threaded workers can be starved by misbehaving operations

Where it comes from: The architectural strength of one event loop per worker becomes a weakness when anything blocks. Disk I/O, DNS resolution, third-party module callbacks that aren’t async — any of these freeze the worker for every connection it owns.

What it costs you: A poorly-tuned cache that thrashes disk can spike latency for thousands of unrelated requests. A third-party Lua module that does synchronous I/O can stall a worker. The blast radius of a single bad operation is much larger than in a thread-per-connection server, where one slow request only blocks one thread.

When this is a dealbreaker: You can’t quite see this one until it bites. The mitigation is discipline: use aio threads for disk-heavy workloads, never use blocking third-party modules in the hot path, profile carefully when adding anything custom.

Downside 6: The module ecosystem is split and aging

Where it comes from: Nginx supports compile-time modules (statically linked at build) and dynamic modules (loaded at runtime, but only since 1.9.11). Many third-party modules predate dynamic-module support and require rebuilding nginx to use. The official ecosystem is curated; third-party modules vary wildly in quality.

What it costs you: When you need a feature that’s not in nginx-core, you may end up running a custom-built nginx — which complicates deployment, security patching, and support. OpenResty solves some of this by bundling a useful set, but binds you to a specific nginx fork. Apache’s a2enmod ecosystem is much richer; Envoy’s filter system is much more first-class.

When this is a dealbreaker: Specialty use cases (WAF, advanced auth, custom protocol handling) where the right answer is in a third-party module. Worth evaluating Envoy or Caddy for greenfield projects in this category.

Downside 7: The “if is evil” problem is a confession of design failure

Where it comes from: The if directive does not work the way any user expects. The official docs literally have a page called “If Is Evil.” There are extensive guides on when if is safe (rarely) and when it’s dangerous (mostly). This is genuinely embarrassing — a config language whose conditional is broken.

What it costs you: Constant friction. Every guide warns you about it; every team eventually has someone who didn’t read the warning and shipped a broken config. The workarounds (map, separate locations) work but require knowing about them, which means experienced staff catching mistakes that should not have been possible.

When this is a dealbreaker: Almost never alone. But combined with the rest of the config-language pain, it’s part of why many teams now prefer Caddy or Envoy for new projects.

Downside 8: Open-source nginx development has slowed and forked

Where it comes from: Igor Sysoev left NGINX, Inc. in 2022. F5 (which owns nginx) is the primary developer of mainline nginx. Several core developers forked to a project called freenginx in 2024, citing concerns about F5’s stewardship. The state of nginx open-source governance is more uncertain than it was five years ago.

What it costs you: Strategically, you’re betting on either F5’s stewardship or the freenginx fork holding. Tactically, day-to-day, it changes little for now. But the velocity of new features in mainline has slowed relative to Envoy, and major architectural improvements (true dynamic config, native service-discovery integration) aren’t coming. If you’re betting infrastructure on nginx’s continued evolution, watch this space.

When this is a dealbreaker: Strategic infrastructure decisions in cloud-native environments. For traditional reverse-proxy work, nginx is still rock-solid and unlikely to change much; “frozen but reliable” is a fine answer for many teams.

These downsides are not reasons to never use nginx. Most of them have lived solutions and most teams ship just fine with nginx as the front door. But going in with eyes open — knowing the config language is hostile, the dynamic story is weak, observability needs supplementing, and the project’s future is more uncertain than it once was — is part of senior judgment.


12. The Taste Test

Good vs bad nginx configs. What the staff engineer notices in a code review.

Bad

server {
    listen 80;
    server_name example.com;
    
    if ($host != example.com) {
        return 301 https://example.com$request_uri;
    }
    
    if ($scheme = http) {
        return 301 https://$host$request_uri;
    }
    
    location / {
        if (!-e $request_filename) {
            rewrite ^(.*)$ /index.php?q=$1 last;
        }
        proxy_pass http://localhost:8080;
        proxy_set_header X-Real-IP $remote_addr;
    }
    
    location ~ \.php$ {
        fastcgi_pass localhost:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

What’s wrong: if-blocks for things that should be separate server blocks. if (!-e) instead of try_files. Missing proxy_http_version, proxy_set_header Host, proxy_set_header X-Forwarded-For, proxy_set_header X-Forwarded-Proto. The PHP location lacks try_files to prevent the executable-upload attack. HTTP and HTTPS not separated. No HSTS. No HTTP/2.

Good

# /etc/nginx/proxy_headers.conf — shared file
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_http_version 1.1;
proxy_set_header Connection "";

# /etc/nginx/conf.d/example.conf
upstream app {
    server 127.0.0.1:8080;
    keepalive 32;
}

# HTTP: redirect to HTTPS, period.
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

# WWW redirect: separate block, no if.
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name www.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    return 301 https://example.com$request_uri;
}

# The real server.
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com;

    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_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    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;

    location = /healthz {
        access_log off;
        return 200 "ok\n";
    }

    location ^~ /static/ {
        root /var/www/example;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location / {
        include /etc/nginx/proxy_headers.conf;
        proxy_pass http://app;
    }
}

What’s right: clean separation of HTTP/HTTPS and apex/www. No if. Shared header file (so we never have the inheritance bug). Explicit keepalive on upstream with the matching proxy_http_version 1.1 and Connection "". Healthcheck is exact-match, access log silenced. Static files use ^~ to skip regex matching, with long cache and immutable hint. Modern TLS, HSTS, security headers. HTTP/2. IPv6 on every listen.

Signals of taste in someone else’s nginx config

When you read someone’s config and want to know if they know what they’re doing, look for:

  • Use of try_files instead of if (!-e ...). Marker of someone who’s read the docs.
  • access_log off on health checks and static assets. Marker of someone who’s debugged log noise.
  • map instead of if for value-based logic. Marker of someone who’s burned by if.
  • Shared include files for proxy_set_header blocks. Marker of someone who’s hit the inheritance reset.
  • Separate server blocks for HTTP→HTTPS, www→apex. Marker of someone who’s been told “if is evil.”
  • keepalive set on every upstream with proxy_http_version 1.1 and Connection "". Marker of someone who’s profiled real traffic.
  • proxy_cache_use_stale with a sensible set of conditions. Marker of someone who’s been on call during a backend outage.
  • Comments explaining why — “this header is here because backend X requires it.” Marker of someone who expects their config to be read by future humans.

Signals of someone in over their head

  • if blocks doing routing.
  • proxy_pass with inconsistent trailing-slash conventions across locations.
  • worker_processes 64 on a 4-core machine.
  • No nginx -t in the deployment pipeline.
  • Long unbroken location ~ ^/(api|admin|user|...)$ regex with a dozen alternatives.
  • Copy-pasted snippets from random tutorials, sometimes with comments still in Russian.
  • try_files $uri $uri/ /index.php?q=$1 last; — last is for rewrite, not try_files (this one doesn’t even do what they think).
  • No HSTS or modern TLS config.
  • error_log off; — and indeed an off file in /etc/nginx.

13. Where to Go Deeper

The curated list, opinionated by usefulness.

  • The official nginx documentation at nginx.org/en/docs/ — the directive reference is the source of truth. Not the prettiest but always accurate. When in doubt, the per-directive doc tells you the context, default, and inheritance rules. Read this for every directive you use the first time.

  • “Inside NGINX: Designed for Performance and Scale” (nginx blog) — the original engineering essay on the master/worker model and event loop. Short, foundational, and the place where the “chess grandmaster” analogy comes from. Read this once.

  • “Pitfalls and Common Mistakes” (nginx wiki at nginx-wiki.getpagespeed.com) — the community catalog of every mistake people make. “If is evil” lives here. Read this twice.

  • “Avoiding the Top 10 NGINX Configuration Mistakes” (F5 blog) — the operational counterpart to the wiki. Covers worker_connections, file descriptors, keepalive, the if/try_files swap. Read this before any production deployment.

  • DigitalOcean’s “Understanding Nginx Server and Location Block Selection Algorithms” — the clearest single explanation of the matching algorithm and request-processing phases. Read this when you first hit a routing surprise.

  • The book “Nginx HTTP Server” by Clément Nedelcu, or “Mastering Nginx” by Dimitri Aivaliotis — full-book treatments. Useful for the “I want to read about nginx for an evening” mode. Both are dated but the architecture they teach hasn’t changed.

  • Gixy (github.com/yandex/gixy) — static analyzer for nginx configs. Catches many of the gotchas in this document automatically. Run it on your configs before reload. Run it in CI.

  • The OpenResty docs and Lapis project — when you need to script nginx, OpenResty is the way. Reading even the introduction will teach you a lot about how nginx itself processes requests.

  • For broader context: “High Performance Browser Networking” by Ilya Grigorik — not nginx-specific but the best book on understanding what’s happening at the HTTP/TCP/TLS layer that nginx is moving bytes through. Knowing this changes how you read every nginx config.


14. The Final Verdict

Nginx is the boring, reliable, slightly grumpy infrastructure tool that the internet runs on. Twenty years after it solved the C10K problem, it still solves it better than almost anything, and the things it doesn’t solve are mostly things it was never trying to solve. The architecture is one of the cleanest in systems software — a small number of workers, an event loop, a tree of declarative config — and you can teach the whole shape of it in twenty minutes. It earns the respect.

What it gets profoundly right is the central architectural bet: that one process per CPU core running a non-blocking event loop is the right model for moving bytes between sockets. That bet has been validated for two decades and it’s still the right bet today. You can argue Envoy is more flexible and Caddy is friendlier, but they’re playing in a game that nginx defined. Equally right is the operational discipline — graceful reloads, worker isolation, conservative defaults, predictable failure modes. Nginx in production almost never surprises you in the bad way. Crashes are rare; resource leaks are rare; reloads work. That reliability is not free, and most software does not give it to you.

What it costs you is the config language and the dynamic-reconfiguration story. The config syntax is a museum of every clever idea that seemed good in 2005 and ages poorly: imperative escape hatches that don’t compose, inheritance rules you have to memorize, a directive (if) so dangerous it has its own dedicated wiki page warning you against using it. You will spend an embarrassing amount of your nginx career debugging configs that look right and aren’t. And the world has moved toward dynamic backends — containers, service meshes, ephemeral pods — in ways the original design didn’t anticipate, and the open-source path forward in that world is uncomfortable: either workarounds via Lua, or accept reload-on-every-change, or pay for Plus, or use something else.

Who should reach for nginx: Teams that need a reliable front door for HTTP traffic, with stable upstreams, where the operational cost of running it is small relative to what’s behind it. Teams serving static content at scale (nothing beats nginx + sendfile()). Teams on traditional VM or bare-metal infrastructure. Teams where SSL termination, reverse proxying, and basic load balancing cover 95% of the requirements. Teams who value mature tooling and large operator pools over architectural elegance.

Who should look elsewhere: Teams deep in Kubernetes with dynamic backends — look at Envoy-based ingress (Istio, Envoy Gateway, Gateway API implementations). Small teams who want zero-touch HTTPS — Caddy. Teams who need service-mesh features (canaries, mirroring, mTLS everywhere) — Envoy or a managed service mesh. Teams whose primary need is pure TCP load balancing — HAProxy. Teams who just want a managed thing — your cloud’s load balancer.

Three things to believe after this document:

  1. Believe that nginx’s architecture is excellent and its config language is terrible. These coexist. Engineers who only see one of them — either the haters who dismiss nginx because of if-evil, or the fans who refuse to acknowledge the config pain — both miss reality.
  2. Don’t believe that nginx is hard to learn. It looks hard because the config is hostile to newcomers. The actual model — workers, events, phases, proxy-first — is simple and small. Once you have it, the config is just trivia.
  3. When you hear “we should replace nginx,” what people usually mean is “we should replace nginx for our Kubernetes ingress” or “we should replace nginx for our service mesh.” That’s often right. Replacing nginx as your edge HTTP server for a traditional web app is almost never urgent and usually a mistake.

The hard-won line: nginx is one of the few pieces of infrastructure where you can be confident it will still be running, unchanged, doing its job, ten years from now. That is not the most exciting property a tool can have, but in production infrastructure, it is the rarest and most valuable one.


The ideas are mine. The writing is AI assisted