Most people meet NGINX as a magic file they copy-paste until the 502 goes away. This post is the opposite: a tour of the model behind that file, drawn straight from the official docs at nginx.org/en/docs. Once the model clicks, the config stops being scary.
Why NGINX is fast (and why the answer matters)
Classic web servers spawn a process or thread per connection. That's fine at 200 connections and catastrophic at 200,000 — each thread eats memory and forces the kernel to context-switch constantly. This is the famous C10K problem.
NGINX was built to dodge it. Instead of "one connection = one thread," it uses a small, fixed number of worker processes, each running a single-threaded, event-driven, non-blocking loop. One worker juggles thousands of concurrent connections by never sitting idle waiting on I/O.That one design decision explains almost everything else about NGINX. Keep it in mind as we go.
The process model: master + workers
When NGINX starts, you get one master process and several worker processes.
┌─────────────────┐
│ master process │ (runs as root, reads config, binds ports)
└───────┬─────────┘
│ manages
┌───────────┼───────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│worker 0│ │worker 1│ │worker 2│ (do the actual request handling)
└────────┘ └────────┘ └────────┘
The master process does the privileged, one-time work:
- Reads and validates configuration
- Binds to privileged ports (like
:80/:443) - Spawns, monitors, and restarts workers
- Handles signals for graceful reloads and upgrades Worker processes do everything else — accepting connections, reading requests, talking to upstreams, sending responses. They're the hot path. A sane default is one worker per CPU core:
worker_processes auto; # matches the number of available cores
events {
worker_connections 1024; # max simultaneous connections *per worker*
}
Your theoretical connection ceiling is roughly worker_processes × worker_connections. With 8 cores and 1024 connections each, that's ~8,000 connections without breaking a sweat — and that's a conservative default.
The trick that makes reloads zero-downtime
Run nginx -s reload and NGINX doesn't drop a single connection. The master:
- Re-reads and validates the new config
- Starts new workers with the new config
- Tells old workers to stop accepting new connections
- Old workers finish their in-flight requests, then exit
This graceful choreography is why you can ship config changes to a busy production server in the middle of the day.
---
## The event loop: how one worker serves thousands
Here's the part that trips people up. A single worker is single-threaded, yet handles thousands of connections. How?
Instead of blocking on I/O, each worker asks the kernel: "tell me when any of these connections has something ready." On Linux that mechanism is
epoll(it'skqueueon BSD/macOS). The worker then loops:
while (true) {
events = wait_for_ready_connections() // epoll_wait — the only place we "block"
for (event in events) {
handle(event) // never blocks; do a slice of work, move on
}
}
The rule inside a worker is sacred: never block. A slow disk read or a slow upstream must not freeze the whole loop, because that loop is serving thousands of other clients. When work would block, NGINX either offloads it (thread pools for disk I/O) or parks the connection and comes back when the kernel says it's ready.
Choose the connection-processing method explicitly if you want, though NGINX auto-selects the best one for your OS:
events {
use epoll; # Linux
worker_connections 4096;
multi_accept on; # accept as many new connections as possible per event
}
This is the whole "why is NGINX so fast" answer in one sentence: it turns I/O waiting into an event notification instead of a parked thread.
How NGINX processes a request
The docs have a dedicated page on this ("How nginx processes a request"), and it's worth internalizing. Requests are routed in two steps: which server block, then which location.
Step 1: pick the server block
NGINX matches the request against listen directives and the Host header (via server_name):
server {
listen 80;
server_name example.com www.example.com;
# ...
}
server {
listen 80 default_server; # fallback when no server_name matches
server_name _;
return 444; # drop connections to unknown hosts
}
server_name matching order: exact name → leading wildcard (*.example.com) → trailing wildcard (www.example.*) → regex. First match wins.
Step 2: pick the location
Within the chosen server, NGINX matches the URI against location blocks. The matching rules are precise and worth memorizing:
location = /exact { } # 1. exact match — highest priority, stops search
location ^~ /assets/ { } # 2. prefix match that *skips* regex if it wins
location ~ \.php$ { } # 3. case-sensitive regex (first match wins)
location ~* \.(jpg|png)$ { } # 4. case-insensitive regex
location / { } # 5. plain prefix — longest match wins as fallback
The priority order is: = beats ^~ beats regex (~/~*) beats plain prefixes. Getting a 404 or serving the wrong file is almost always a location precedence surprise — this table is the cure.
The config blocks you'll actually use
NGINX config is a tree of contexts. Directives are only valid in certain contexts. Here's the shape:
# main context
worker_processes auto;
events { ... } # connection processing
http { # everything HTTP lives here
include mime.types;
sendfile on; # zero-copy file serving via the kernel
upstream app { ... } # a pool of backend servers
server { ... } # a virtual host
}
stream { ... } # raw TCP/UDP proxying (databases, gRPC, etc.)
Serving static files
server {
listen 80;
server_name static.example.com;
root /var/www/html;
location / {
try_files $uri $uri/ =404; # try file, then dir, else 404
}
location /assets/ {
expires 30d; # aggressive caching for hashed assets
add_header Cache-Control "public, immutable";
}
}
try_files is the workhorse here — it's how SPAs get their "always fall back to index.html" behavior:
location / {
try_files $uri $uri/ /index.html; # client-side routing friendly
}
Reverse proxy to an app server
This is probably why you're here. NGINX sits in front of your Node/Python/Go app and forwards requests:
upstream backend {
server 127.0.0.1:3000;
server 127.0.0.1:3001;
# load balancing: round-robin by default
# least_conn; # send to the worker with fewest active conns
# ip_hash; # sticky sessions by client IP
keepalive 32; # reuse upstream connections
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://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_http_version 1.1;
proxy_set_header Connection ""; # required for upstream keepalive
}
}
Those proxy_set_header lines matter more than they look. Without them, your backend sees NGINX's IP as the client, loses the original scheme, and can generate broken redirects. This four-line block is the single most copy-pasted (and most misunderstood) snippet in NGINX land.
Load balancing methods, at a glance
| Method | Directive | Use when |
|---|---|---|
| Round robin | (default) | Backends are roughly equal |
| Least connections | least_conn; |
Requests have uneven duration |
| IP hash | ip_hash; |
You need sticky sessions |
| Weighted | server ... weight=3; |
Backends have different capacity |
HTTPS in about ten lines
The docs' "Configuring HTTPS servers" page boils down to this:
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m; # reuse handshakes across connections
}
# Redirect all HTTP to HTTPS
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
ssl_session_cache is the performance sleeper: TLS handshakes are expensive, and caching sessions across connections cuts CPU noticeably on busy sites.
Rate limiting: cheap insurance
NGINX can shield your backend from bursts and abuse before a request ever reaches your app. It uses a leaky bucket algorithm:
http {
# define a zone: 10MB of state, 10 requests/sec per client IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay; # allow short bursts of 20
proxy_pass http://backend;
}
}
}
There's a sibling directive, limit_conn, for capping concurrent connections per client. Both live entirely in NGINX — no app code, no extra service.
The commands worth memorizing
nginx -t # test config syntax before applying — do this ALWAYS
nginx -s reload # graceful reload, zero dropped connections
nginx -s quit # graceful shutdown (finish in-flight requests)
nginx -s stop # fast shutdown (drop everything now)
nginx -T # dump the full, resolved config (includes all `include`s)
nginx -v # version
nginx -V # version + build flags + configured modules
Rule of thumb: never reload without running nginx -t first. A syntax error caught by -t is a non-event; the same error hitting reload on some setups can take the server down.
Debugging: read the logs like a local
Two logs, two jobs:
nginx
http {
log_format main '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'rt=$request_time uct="$upstream_connect_time" '
'urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
}
Adding $request_time and $upstream_response_time to your access log is the fastest way to answer "is it NGINX or is it the backend that's slow?" — a question you will be asked during an incident.
For deep debugging, a build with --with-debug unlocks error_log ... debug;, which traces the request lifecycle in detail. That's covered in the docs' "A debugging log" page.
The mental model, in five lines
If you remember nothing else:
- Master configures, workers serve. Privilege separation + zero-downtime reloads.
- One worker, one event loop, thousands of connections. Never block the loop.
-
Requests route in two steps:
serverblock, thenlocation— and precedence is not intuitive, so learn the table. -
Contexts nest:
main → http → server → location. Directives are context-scoped. -
nginx -tbefore everyreload. Always. Everything in the config file is a consequence of these five ideas. Once you see the event loop behind the directives, NGINX stops being a black box and starts being the most predictable thing in your stack. ---
Where to go next in the docs
- Beginner's Guide — a hands-on first config
- How nginx processes a request — the routing internals in depth
- Admin Guide — proxying, load balancing, compression, caching
-
Module reference — every directive, grouped by module (
ngx_http_core_module,ngx_http_proxy_module,ngx_http_upstream_module, …)
All at nginx.org/en/docs.
Found this useful? Drop your gnarliest NGINX config gotcha in the comments — the location precedence ones are always a good time. 🚀
Top comments (0)