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"; }| Request | Winner | Why |
|---|---|---|
/ | A | exact match wins immediately |
/images/logo.png | E | longest prefix is C, but regexes are checked before plain prefixes apply, and E matches |
/static/logo.png | D | ^~ on the longest matching prefix suppresses the regex pass |
/about | B | no 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.