DEV Community

Mukul S
Mukul S

Posted on

NGINX, Actually Explained: Architecture, Config, and the Mental Model That Makes It Click

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)
  └────────┘  └────────┘  └────────┘
Enter fullscreen mode Exit fullscreen mode

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*
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Re-reads and validates the new config
  2. Starts new workers with the new config
  3. Tells old workers to stop accepting new connections
  4. 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's kqueue on 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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.)
Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Master configures, workers serve. Privilege separation + zero-downtime reloads.
  2. One worker, one event loop, thousands of connections. Never block the loop.
  3. Requests route in two steps: server block, then location — and precedence is not intuitive, so learn the table.
  4. Contexts nest: main → http → server → location. Directives are context-scoped.
  5. nginx -t before every reload. 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)