nginx

nginx is the web server, reverse proxy, and load balancer that Igor Sysoev wrote against the C10K problem and that now fronts a huge share of the internet's traffic; the canonical source lives at nginx.org with the official repository at github.com/nginx/nginx. This is a full chapter, not a tour: a practical tutorial, then the complete life of one HTTP request from the accept queue through the epoll loop, the parsing state machine, the eleven processing phases, and the write chain, deep dives into the four load-bearing subsystems, a staged plan for reading the C source, and labs you can run tonight. File paths were verified against the repository in the 1.29 mainline era.

Part I: The mental model

client TCP SYN
   │
   ▼
kernel accept queue ──── master process (root: reads config,
   │                     binds sockets, supervises workers)
   ▼
worker process (one per core, unprivileged)
   │  epoll_wait: which sockets are ready?
   ▼
header parsing state machine (resumable, byte at a time)
   │
   ▼
eleven request phases (rewrite → find-config → access → ... → content)
   │
   ├── static: open file, sendfile
   └── proxy:  ngx_http_upstream → backend connect/send/receive
   ▼
filter chain (headers, gzip, chunked, ...) → write chain (buffers)
   │
   ▼
writev / sendfile → kernel → client

The one-sentence identity: nginx is a small fixed set of single-threaded event loops, each multiplexing tens of thousands of connections through non-blocking state machines, configured by a declarative language that is compiled once at startup. Every famous nginx property, the tiny memory footprint, the zero-downtime reloads, the module ecosystem, falls out of one of those three clauses.

The design is a bet made in 2002 and still paying off: instead of one thread or process per connection, which drowns in stacks and context switches at ten thousand connections, run roughly one worker per CPU core and never block. A worker asks the kernel via epoll (Linux) or kqueue (BSD, macOS) which of its connections have events, runs a short callback for each, and asks again. Concurrency scales with memory per connection, a few kilobytes, rather than with schedulable threads. The price is a discipline that shapes every line of the source: no callback may ever wait, so everything, header parsing included, is written as a state machine that can stop mid-byte and resume when the next event arrives.

The other half of the model is that nginx is two programs in one: a privileged master that owns the config and the listening sockets, and disposable workers that own the traffic. Reloads, upgrades, and crashes are all handled by replacing workers under a master that never stops listening, which is why nginx restarts are something other servers have and nginx mostly does not need.

Part II: Using it

Install and first session

sudo apt install nginx     # Debian/Ubuntu (config in /etc/nginx)
brew install nginx         # macOS (config in $(brew --prefix)/etc/nginx,
                           #        listens on 8080 by default)

The three commands you will actually run daily are check, reload, and quit, and the check output is worth recognizing on sight:

$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

$ sudo nginx -s reload   # apply new config, zero dropped connections
$ sudo nginx -s quit     # graceful shutdown

nginx -T prints the entire effective config with all includes expanded, the single most useful debugging command on a machine you did not configure yourself. On Debian-family systems the server runs under systemd (systemctl status nginx) and site configs live in sites-enabled/; other layouts use conf.d/. Same server, different includes.

The canonical config, and the traps around it

The shape of most nginx deployments in the wild is ten lines: static files served directly, everything else forwarded to an app server.

server {
    listen 80;
    server_name example.com;

    location /static/ {
        root /var/www/myapp;
    }

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

TLS termination, caching, compression, and rate limiting are all extra directives in this same file rather than extra software. Now the two traps every beginner hits. First, root versus alias: root appends the full request URI to the path, alias replaces the location prefix.

# Request: GET /static/app.css

location /static/ {
    root /var/www/assets;      # serves /var/www/assets/static/app.css
}

location /static/ {
    alias /var/www/assets/;    # serves /var/www/assets/app.css
}

If your files 404 and the path in the error log contains the location prefix twice, this is why. Second, proxy_pass changes meaning with a trailing URI:

location /api/ {
    proxy_pass http://127.0.0.1:8000;     # backend sees /api/users
    proxy_pass http://127.0.0.1:8000/;    # backend sees /users
}

With no URI part, nginx forwards the request URI unchanged; with one (even just /), the matched location prefix is replaced by it. Both behaviors are correct; mixing them up is a silent route rewrite. A third habit worth forming immediately: proxy_set_header directives do not accumulate across levels, so defining even one in a location discards all the ones inherited from server. Part V explains why the config behaves that way.

Part III: When it is the right tool

nginx is the default edge: TLS termination, static files, reverse proxying, load balancing, response caching, and request policing (the limit_req leaky bucket is the same algorithm family I work through in my rate limiter design write-up). It shines wherever many slow, untrusted client connections must be absorbed cheaply in front of a small number of fast backends, and it remains the engine inside much of the cloud: the long-dominant Kubernetes ingress controller, ingress-nginx, is template-generated nginx config around this exact binary (my Kubernetes walkthrough covers where that sits in the stack).

The honest alternatives: Caddy wins on operator ergonomics, automatic TLS certificates and a far friendlier config, at some cost in raw efficiency and module depth. HAProxy is the sharper pure load balancer, with richer health checking and connection-level control, but it does not serve files. Envoy is built for dynamic control planes, config delivered over an API rather than files, which is why service meshes standardized on it; as a hand-configured edge it is heavier than you want. Apache httpd still makes sense for .htaccess-style per-directory delegation and embedded-interpreter deployments. nginx's own limit is symmetric with its strength: its config is static per reload, its worker model punishes anything that blocks, and business logic belongs behind it, not in it.

The architecture-shaped warning: never expose a thread-or-process-per-connection app server directly to the internet when you could put nginx in front, because slow clients occupy an app-server worker for the entire transfer but cost an nginx worker almost nothing.

DANGEROUS                            SAFE
internet ── gunicorn (8 workers)     internet ── nginx (event loop,
  8 slow phones uploading              absorbs slow clients, buffers
  = all workers busy                   requests and responses)
  = site down for everyone               │ fast local socket
                                       gunicorn workers stay busy
                                       only for actual app time

This is proxy buffering, and it is nginx's quiet superpower: the backend is only occupied for the milliseconds it takes to hand nginx the response, while nginx drips it to a phone on a train for as long as that takes. Lab 4 measures it.

Part IV: The full life of one request

The canonical operation is one HTTP request through a worker. Follow GET /api/users against the config in Part II, naming the actual files at each stage.

Stage 1: accept

The master bound the listening sockets before forking, so every worker inherits them. When the kernel completes a TCP handshake it queues the connection; a worker's epoll reports the listening socket readable and ngx_event_accept() (src/event/ngx_event_accept.c) accepts it, pulls a free ngx_connection_t from the worker's preallocated pool (sized by worker_connections, default 512), sets the socket non-blocking, and registers a read handler. Which worker wins an incoming connection is its own small subsystem: modern kernels spread accepts across workers (with reuseport available to give each worker its own accept queue), replacing the old accept-mutex dance.

Stage 2: the worker event loop

The worker's life is one function, ngx_process_events_and_timers() in src/event/ngx_event.c: compute the nearest timer deadline, call the poller with that timeout, run the handler of every ready event, then expire due timers. The poller behind it on Linux is src/event/modules/ngx_epoll_module.c, which registers connection sockets edge-triggered; timers live in a red-black tree (src/event/ngx_event_timer.c over src/core/ngx_rbtree.c), which is how a worker tracks tens of thousands of per-connection timeouts with one clock. Everything that happens from here on is a handler invoked by this loop, and no handler may block, ever.

Stage 3: parsing the request line and headers

The read handler for a new connection waits for bytes, then enters ngx_http_process_request_line() and ngx_http_process_request_headers() in src/http/ngx_http_request.c, which call the parser in src/http/ngx_http_parse.c. The parser is the purest expression of the no-blocking rule: a hand-written state machine with states like sw_method, sw_uri, and sw_header_value that consumes one byte at a time and, when the buffer runs dry mid-header, simply returns NGX_AGAIN; the request object stores the current state, the worker goes off to serve other connections, and parsing resumes at the same byte when epoll reports more data. Parsed headers accumulate in the ngx_http_request_t, allocated from a per-request memory pool (src/core/ngx_palloc.c) that will be freed in one shot at the end, which is why nginx code almost never frees anything explicitly.

Stage 4: the eleven phases

With headers complete, ngx_http_core_run_phases() in src/http/ngx_http_core_module.c walks the phase list. The eleven, from the enum in ngx_http_core_module.h: POST_READ (e.g. realip recovering client addresses), SERVER_REWRITE, FIND_CONFIG (location matching, no module handlers allowed), REWRITE, POST_REWRITE (loop back if the URI changed), PREACCESS (limit_req, limit_conn), ACCESS (allow/deny, auth), POST_ACCESS, PRECONTENT (try_files, mirror), CONTENT, and LOG. Our request matches location /api/ at FIND_CONFIG, sails through the empty check phases, and reaches CONTENT, where the location's content handler is proxy_pass's, registered by src/http/modules/ngx_http_proxy_module.c. A request can be parked at any phase, a handler that must wait returns and resumes on a later event, so "walking the phases" can span many loop iterations.

Stage 5: upstream, the proxying machine

The proxy module itself mostly writes and reads HTTP; the hard parts, connecting, pooling, retrying, buffering, live in the shared upstream machinery of src/http/ngx_http_upstream.c, the largest file in the tree. The sequence: pick a peer via the load-balancing module (ngx_http_upstream_round_robin.c by default), start a non-blocking connect (src/event/ngx_event_connect.c), and when the socket goes writable send the proxied request that the proxy module's create_request built (this is where proxy_set_header values were baked in). Then read and parse the backend's status line and headers, and hand the body to the pipe engine in src/event/ngx_event_pipe.c, which shuttles bytes from the upstream socket to the client socket through a bounded set of buffers, spilling to a temp file if the backend outruns a slow client. If the backend fails, upstream logic retries the next peer per proxy_next_upstream. For a static request this whole stage is replaced by src/http/modules/ngx_http_static_module.c: open the file (through the open-file cache), and emit a buffer that points at a file range rather than memory.

Stage 6: the filter and write chains

Nothing goes to the client directly; it goes through two chains of filters built at startup. The header filter chain ends at src/http/ngx_http_header_filter_module.c, which renders the status line and headers into a buffer. Body output flows as chains of ngx_buf_t (buffers that may point at memory or at file ranges) through body filters: copy, postpone, gzip, chunked, each a module that transforms the chain and calls the next, until src/http/ngx_http_write_filter_module.c hands the accumulated chain to the connection's send function, ngx_writev_chain() or the sendfile path (src/os/unix/ngx_linux_sendfile_chain.c), which writes as much as the socket accepts. A short write is not an error: the unsent tail stays queued, a write event is armed, and the loop returns later, which is proxy buffering meeting the event model.

Stage 7: finalize, log, keep alive

ngx_http_finalize_request() runs the LOG phase (access log handlers), then either destroys the request and waits for the next one on a keep-alive connection or closes it. The request pool is freed in one call, returning every allocation the request made. The connection object goes back to the free list, and the worker, which never knew it was handling this request "specially" among thousands, keeps looping.

Part V: Internals deep dives

Deep dive 1: the master/worker model and zero-downtime everything

            master (root: parse config, bind :80/:443, never serves)
              │ fork
   ┌──────────┼──────────┐        signals to master:
worker     worker     worker      HUP   reload config
(user nginx, serve traffic)       USR2  upgrade binary on the fly
   ▲                              WINCH gracefully stop workers
   └── socketpair channels ───    QUIT  graceful shutdown

The split is privilege separation first: only the master runs as root, and only to read certificates and bind low ports; workers drop to the user directive's account before touching a byte of traffic. The processes talk over per-worker Unix socketpairs (src/os/unix/ngx_channel.c), and the supervision loop lives in src/os/unix/ngx_process_cycle.c, which is the file to read to see the whole story in one place: on SIGHUP the master parses the new configuration into a fresh cycle object (src/core/ngx_cycle.c), and only if it parses cleanly forks new workers with it, then asks the old workers to shut down gracefully; they stop accepting, finish their in-flight requests, and exit. A reload is not "restart quickly", it is two complete generations of workers briefly coexisting, which is why nothing is dropped and why a broken config never takes the site down. During a drain you can see the old generation in the process list, marked worker process is shutting down. The binary upgrade is the same idea one level up: SIGUSR2 makes the master exec the new binary as a second master sharing the same inherited listening sockets, and signals then retire the old generation, so even replacing the executable drops nothing.

Deep dive 2: the event core, and why blocking is forbidden

A worker is one thread; there is no scheduler to save you. If a handler blocks for 100 ms, every one of that worker's connections, potentially tens of thousands, freezes for 100 ms. This is why the codebase contains no ordinary read()-and-wait anywhere in the request path, why DNS resolution has its own async resolver (src/core/ngx_resolver.c), and why disk I/O, the one thing epoll cannot make non-blocking on Linux files, gets special treatment: sendfile, async file I/O where configured, and thread pools (src/core/ngx_thread_pool.c, aio threads) as the escape hatch for serving big files from slow disks. The connection math is worth doing once: worker_processes auto (one per core) times worker_connections (default 512, commonly raised into the tens of thousands) bounds concurrent connections, and each proxied request consumes two, one to the client and one to the backend. The trap to internalize: a third-party module that makes a synchronous call, a blocking database query, a filesystem stat on NFS, silently converts nginx back into the architecture it was built to replace, and the symptom is mysterious multi-second latency on unrelated cheap requests served by the same worker.

Deep dive 3: the phase engine and the module system

nginx modules famously compose without knowing about each other, and the phase engine is the mechanism. At startup, each module's postconfiguration hook may push a handler into the array for one of the phases that accept handlers; the core then builds a flat execution list with a checker function per phase that interprets handler return codes: roughly, "I handled it, finalize", "decline, next handler", or "waiting, park the request here". Modules never call each other; they only occupy slots in a pipeline whose order is fixed by the core. That is why access rules, an auth subrequest, and an IP allow list all combine predictably: they are all just ACCESS-phase handlers. The filter half mirrors it: each filter module, at startup, saves the current head of the chain (ngx_http_top_body_filter) and installs itself as the new head, so the chain is built by prepending in reverse registration order, and calling "the next filter" is calling the saved pointer. Two consequences follow. Configuration order of modules matters for filters (it fixes gzip-before-chunked and friends), and a module can be dropped into the build (--add-module or dynamic load_module) and hook the pipeline without a single change elsewhere, which is how the OpenResty/Lua ecosystem grafted a scripting language onto a server that never planned for one.

Deep dive 4: the config is a compiled declarative language

The config file looks like settings but is handled like source code: src/core/ngx_conf_file.c tokenizes it, each directive is dispatched to the module that declared it, and the result is a tree of per-module C structs, compiled once at startup and only consulted, never re-parsed, at request time. rewrite and if conditions literally compile to a tiny script engine (src/http/ngx_http_script.c) evaluated at request time. Inheritance across http, server, and location is implemented by each module's merge functions, and the rule that generates the classic surprises is this: values inherit only when the inner level sets nothing; array-valued directives like proxy_set_header and add_header replace the entire inherited set the moment you define even one locally.

Location selection follows fixed rules, worth working through once against a config like this:

location = /            { return 200 "A exact /"; }
location /              { return 200 "B prefix /"; }
location /images/       { return 200 "C prefix /images/"; }
location ^~ /static/    { return 200 "D prefix, no regex"; }
location ~* \.(png|jpg)$ { return 200 "E regex images"; }
RequestWinnerWhy
/Aexact match wins immediately
/images/logo.pngElongest prefix is C, but regexes are checked before plain prefixes apply, and E matches
/static/logo.pngD^~ on the longest matching prefix suppresses the regex pass
/aboutBno regex matches, fall back to longest prefix

The algorithm: find the longest matching prefix location; if it is exact (=) use it now; if it is marked ^~ skip regexes; otherwise try the regex locations in the order they appear in the file and take the first match, falling back to the remembered prefix. Note what is absent: file order never matters for prefixes, and always matters for regexes. And the famous trap, "if is evil", is the declarative machinery leaking: if inside location is implemented as an implicit nested location that the request is re-homed into, so content-related directives inside it can be ignored or misfire (the community documents the pathological cases, and the safe subset: return and rewrite inside if are fine). try_files and map exist largely so you do not need if.

Part VI: Reading the repository

The source is compact C under one src/ tree, and it reads best in dependency order, with the official development guide open beside it.

Stage 0, orientation. Skim the development guide's sections on strings, buffers, and event handling; build from a release tarball with ./configure && make so you can add ngx_log_error lines later. Questions you should answer: what are ngx_str_t, ngx_buf_t, and a cycle?

Stage 1, the idioms. Read src/core/ngx_string.h, src/core/ngx_palloc.c (memory pools), src/core/ngx_array.c, src/core/ngx_list.c, and src/core/ngx_buf.c. Questions: why do requests almost never free memory? Why are counted strings used instead of C strings? What can an ngx_buf_t point at besides memory?

Stage 2, the processes. src/core/nginx.c (main()), then src/os/unix/ngx_process_cycle.c with ngx_process.c and ngx_channel.c. Questions: exactly what happens on SIGHUP, step by step? How do workers learn they should exit? Why do old and new workers coexist during a reload?

Stage 3, the event loop. src/event/ngx_event.c (ngx_process_events_and_timers), src/event/modules/ngx_epoll_module.c, src/event/ngx_event_timer.c, and src/event/ngx_event_accept.c. Questions: where does the loop's timeout come from? What data structure holds timers and why that one? What limits concurrent connections per worker?

Stage 4, HTTP. src/http/ngx_http_request.c from ngx_http_wait_request_handler down, dipping into src/http/ngx_http_parse.c to see one state machine in full, then the phase engine in src/http/ngx_http_core_module.c with the enum in its header. Questions: how does parsing survive a packet boundary mid-header? What can a phase handler return, and what does each return value make the checker do? Which phases accept no handlers?

Stage 5, the payoff. src/http/ngx_http_upstream.c with src/event/ngx_event_pipe.c for proxying, the filter pair ngx_http_header_filter_module.c and ngx_http_write_filter_module.c, and then one optional module read end to end; src/http/modules/ngx_http_limit_req_module.c is a perfect choice, a complete leaky-bucket rate limiter in one file. Questions: what happens when the client is slower than the backend? Where do proxy buffers spill to disk? How does a module you have never seen attach itself to the request path?

Where not to start: ngx_http_upstream.c itself (the biggest file in the tree, read it fifth, not first), the HTTP/2 and HTTP/3 trees under src/http/v2/ and src/http/v3/, the async resolver, and ngx_http_script.c, all of which assume fluency in the idioms from Stages 1 and 3.

Part VII: Hands-on labs

Each lab teaches one deep-dive concept. They assume a Linux box (or container) with nginx installed and a shell backend: python3 -m http.server 8000 works for all of them.

Lab 1: a reverse proxy in ten lines, reloaded under load. Install the Part II config, start hey in one terminal against the proxy while reloading in another:

hey -z 30s -c 50 http://localhost/ &
sudo nginx -s reload
sudo nginx -s reload

When hey prints its summary, check the status code report: all 200s, no connection errors, despite two full worker-generation swaps mid-run. Then break the config on purpose (delete a semicolon), run nginx -t, and reload anyway: traffic continues on the old config because the master refuses to promote a config that does not parse. Concept: reload is two coexisting worker generations, not a restart.

Lab 2: location matching, empirically. Put the five-location config from Deep dive 4 in a test server block (add default_type text/plain;), then:

for p in / /about /images/logo.png /static/logo.png /images/x.gif; do
  printf '%-22s → ' "$p"; curl -s "http://localhost$p"; echo
done

Predict each winner before running it; the surprising two are /images/logo.png (regex beats longer prefix) and /static/logo.png (^~ suppresses the regex). Then reorder the location blocks and rerun to prove that prefix order never matters and regex order always does. Concept: the fixed matching algorithm from the config-language deep dive.

Lab 3: watch the process model breathe.

ps -ef | grep [n]ginx        # one root master, N unprivileged workers
sudo kill -HUP $(cat /var/run/nginx.pid)
ps -ef | grep [n]ginx        # new worker PIDs

Now hold a connection open (curl against a slow endpoint, or a WebSocket) and reload again: ps shows an extra worker labeled nginx: worker process is shutting down that lingers until your connection closes, the old generation draining. Inspect sockets with ss -tlnp to see all workers sharing the same listeners. Concept: master/worker supervision and graceful drain from Deep dive 1.

Lab 4: proxy buffering, measured. Make the backend slow and streaming, a tiny script that prints a line per second for ten seconds, and compare time-to-first-byte through the proxy:

curl -s -o /dev/null -w 'ttfb=%{time_starttransfer} total=%{time_total}\n' \
     http://localhost/stream            # proxy_buffering on (default)
# add: proxy_buffering off;  reload, repeat

With buffering on, first byte arrives when nginx has a full buffer (or the response ends), and the backend connection is released early; with it off, bytes flow immediately, which is what you want for SSE and long-polling (X-Accel-Buffering: no lets the backend request this per response). Then fetch a large file through the proxy with a rate-limited client (curl --limit-rate 50k) and watch ss on port 8000: buffered, the backend socket closes almost instantly while nginx spoon-feeds the client; unbuffered, the backend is held hostage for the whole transfer. Concept: buffering decouples backend occupancy from client speed, the safe-architecture diagram made visible.

Lab 5: make if be evil. Configure the documented pathological case, an if with a content-adjacent directive inside a location, alongside the try_files version of the same intent, and compare behavior with curl -i. Keep the takeaway modest and precise: return and rewrite inside if behave; mixing if with content directives re-homes the request into an implicit location with half-copied settings. Concept: the declarative machinery under an imperative-looking directive.

Part VIII: Questions and model answers

Understanding checks; try answering before reading.

1. What is nginx in one sentence, structurally? A privileged master supervising a few single-threaded, event-driven workers, each multiplexing enormous numbers of non-blocking connections through a fixed phase pipeline and filter chain, configured by a declarative language compiled at startup.

2. Why one worker per core instead of a thread per connection? Threads cost stacks, scheduler entries, and context switches, which is what collapsed at C10K. An event loop costs a few kilobytes per connection and one epoll_wait per batch of ready events, so concurrency scales with memory, and pinning roughly one worker per core removes contention.

3. Walk through what happens on nginx -s reload. The signal (HUP) goes to the master, which parses the new config into a fresh cycle; on any error it logs and keeps the old workers untouched. On success it forks new workers with the new config and asks the old ones to shut down gracefully: they stop accepting, drain in-flight requests, then exit. Two generations coexist during the drain, so no connection is dropped.

4. How does a binary upgrade with zero downtime work? SIGUSR2 makes the master exec the new binary as a second master that inherits the same listening socket file descriptors, so both generations accept simultaneously; WINCH retires the old workers, QUIT the old master, and if the new binary misbehaves the old master can be revived to roll back.

5. Why is the header parser written as a resumable state machine? Because headers arrive in arbitrary TCP-sized pieces and a worker may never wait for more bytes. The parser consumes what is buffered, stores its exact state (down to mid-header-name) in the request, returns NGX_AGAIN, and resumes at the next read event, so a slow client costs state, not a blocked thread.

6. Name the eleven phases and what runs at three of them. Post-read, server-rewrite, find-config, rewrite, post-rewrite, preaccess, access, post-access, precontent, content, log. Examples: limit_req at preaccess, allow/deny and auth at access, try_files at precontent, with proxying and static files at content and access logging at log.

7. How do two modules that have never heard of each other compose correctly? Neither calls the other; each registers handlers into fixed phases (or splices into the filter chain), and the core's phase engine and chain order define all interaction. Composition is positional, not call-graph-based, so adding a module cannot break another module's control flow.

8. What does the upstream machinery add beyond "open a socket to the backend"? Peer selection and load balancing, non-blocking connects with timeouts, request buffering and retrying on the next peer, header parsing of the backend response, and the event-pipe that moves the body between two sockets of different speeds with bounded memory and temp-file spill.

9. Why does defining one proxy_set_header in a location drop the ones from the server block? Inheritance is implemented per module by merge functions with the rule "inner level set means inner level wins", and for array-valued directives the unit of setting is the whole array. Defining any element locally means the local array replaces the inherited one; nginx has no append-across-levels.

10. A regex location beats a longer prefix location. Is that a bug? No, it is the documented algorithm: remember the longest matching prefix, but check regexes first in file order unless the remembered prefix was exact (=) or marked ^~. If prefix should win, mark it ^~.

11. Why is "if" considered evil, and what is the safe subset? if inside location creates an implicit nested location and re-homes the request into it, so content-related directives inside behave unpredictably. return and rewrite inside if are safe; for existence checks use try_files, and for value dispatch use map.

12. When would you choose HAProxy, Caddy, or Envoy instead? HAProxy for the most surgical pure load balancing and health checking with no file serving; Caddy when operator simplicity and automatic TLS outweigh tuning depth; Envoy when config must be pushed dynamically from a control plane, as in service meshes. nginx holds the middle: edge proxy, static content, caching, and an enormous module ecosystem in one static-config binary.

13. Cheap requests intermittently take seconds, but only on one worker's connections. Diagnose. Something on that worker's event loop is blocking: a module doing synchronous I/O, a stat on a hung network filesystem, disk reads without aio threads. Every connection multiplexed by the blocked worker stalls together; correlate with strace -p on the slow worker and look for a long syscall.

14. What does proxy buffering actually decouple, and when do you turn it off? It decouples backend occupancy from client speed: nginx slurps the response into buffers (spilling past them to temp files) and frees the backend, then drips bytes to slow clients. Turn it off for SSE, long-polling, and streaming, where time-to-first-byte and continuous flow matter more than backend efficiency.

15. Why can nginx -t promise so much? Because the config is compiled: the full file is parsed, every directive dispatched to its owning module, contexts validated, and the per-module structs built, the same work a reload would do, minus swapping workers. What it cannot prove is runtime behavior: an upstream that is down or a cert that expires tomorrow still tests "successful".

16. Where does memory for a request come from and go? From a per-request pool (ngx_palloc): allocations are pointer bumps, nothing is individually freed, and finalizing the request destroys the pool in one call. This trades peak precision for speed and the near-elimination of leak and use-after-free bugs in request handling.

Part IX: Design lessons

Separate the control plane from the data plane. The master owns config, sockets, and lifecycle; workers own traffic. Because the planes are separate processes, config changes and binary upgrades become worker replacement, not downtime. The same split defines Envoy plus its control plane and every orchestrator, including the Kubernetes controller/kubelet divide.

If you forbid blocking, forbid it everywhere. The event model only works because it is total: parsing, proxying, DNS, even config-time versus request-time work are all structured so no handler waits. Half-async systems get the complexity without the benefit; Node.js, Redis, and every serious event-driven server relearn this rule.

Fixed extension points beat free composition. Modules cannot call each other, only occupy phases and filter slots, so the interaction surface is the pipeline definition itself and adding a module is safe by construction. This is middleware done rigorously, and it is why thousands of third-party modules coexist; compare plugin systems where any hook can call anything and upgrades break the world.

Compile configuration, then only read it. Parsing once at startup into structs makes request-time behavior fast and makes nginx -t a real proof; declarative-with-fixed-selection semantics (location matching) keeps behavior analyzable in a way ad-hoc scripting never is. The same pattern appears in query planners, terraform plan, and FPGA synthesis: front-load interpretation, freeze the hot path.

Pool your allocations to match your lifetimes. Per-request memory pools turn thousands of malloc/free pairs into one bulk free that cannot leak. Arena allocation keyed to a natural lifetime shows up in compilers (per-function arenas), game engines (per-frame), and Apache (also per-request); it is the C answer to a problem GC languages solve at higher cost.

Part X: Memorization framework

One sentence: a root master compiles the config and supervises per-core workers, each an epoll loop driving resumable state machines that carry every request through eleven fixed phases and out through a filter chain, with buffering decoupling fast backends from slow clients.

accept → epoll loop → parse (state machine) → 11 phases
       → content (static | upstream+pipe) → filters → write chain → kernel

The chain mapped to source files:

master/workers   src/os/unix/ngx_process_cycle.c
epoll loop       src/event/ngx_event.c + src/event/modules/ngx_epoll_module.c
accept           src/event/ngx_event_accept.c
parse            src/http/ngx_http_request.c + ngx_http_parse.c
phases           src/http/ngx_http_core_module.c (+ enum in the .h)
upstream/pipe    src/http/ngx_http_upstream.c + src/event/ngx_event_pipe.c
filters/write    src/http/ngx_http_*_filter_module.c → ngx_http_write_filter_module.c
to the kernel    src/os/unix/ngx_writev_chain.c / ngx_linux_sendfile_chain.c

Memorize these:

The phase list. post-read, server-rewrite, find-config, rewrite, post-rewrite, preaccess, access, post-access, precontent, content, log. Eleven, fixed, in that order.

The signal set. HUP reload, USR1 reopen logs, USR2 upgrade binary, WINCH drain workers, QUIT graceful stop, TERM fast stop. All sent to the master; nginx -s is sugar over them.

The location algorithm. Longest prefix remembered; = wins instantly; ^~ blocks regexes; otherwise first matching regex in file order wins, else the remembered prefix.

The inheritance rule. Inner level set means inner level wins, and for array directives (proxy_set_header, add_header) one local definition replaces the whole inherited set.

The blocking rule. One worker is one thread; anything that blocks it blocks every connection it holds.

Key takeaway: nginx endures because three ideas reinforce each other: a few single-threaded workers that never block, a fixed phase pipeline that lets independent modules compose, and a declarative config compiled once at startup. Read the process cycle, the event loop, and the phase engine, and the rest of the codebase turns from folklore into pattern; run the labs, and the folklore about reloads, location matching, and buffering turns into things you have measured.