Home / Blog / DevOps
DevOps

Understanding Nginx: Architecture, Internals, and Configs Beyond the Basics

PublishedJun 29 · 2026
Read9 min
Views6
nginx devops reverse proxy load balancing web server infrastructure
Share
Understanding Nginx: Architecture, Internals, and Configs Beyond the Basics

What Nginx actually is, why its event-driven architecture makes it fast, and the advanced configuration patterns — reverse proxying, load balancing, caching, rate limiting, and API-gateway tricks — that separate a copy-pasted config from one you truly understand.

Nginx (pronounced “engine-x”) quietly powers a huge slice of the internet. It started in 2004 as one engineer’s answer to a specific scaling wall — the so-called C10k problem, the challenge of serving 10,000 concurrent connections on a single machine — and grew into the Swiss-army knife of modern infrastructure: web server, reverse proxy, load balancer, TLS terminator, and cache, all in one small binary.

Most teams run Nginx every day without ever looking past a config someone pasted years ago. This article goes the other way: what Nginx actually is, why it’s fast, how it processes a request internally, and the configuration patterns worth understanding rather than copying.

What Nginx Is — and Why It’s Fast

Older web servers traditionally dedicated a thread or process to each connection. That model is intuitive but expensive: 10,000 slow clients means 10,000 mostly-idle threads, each holding a stack and forcing the kernel to context-switch between them. Memory and scheduling overhead become the bottleneck long before the CPU does.

Nginx takes a different approach. It uses a small number of worker processes — typically one per CPU core — and each worker runs a non-blocking, event-driven loop built on the operating system’s efficient event notification facility (epoll on Linux, kqueue on BSD/macOS). A single worker can juggle tens of thousands of connections because it never sits and waits on any one of them:

loop forever:
    ready = epoll_wait()        // which of my connections have data right now?
    for each ready connection:
        do a small, non-blocking chunk of work
        (read what's available, write what fits, then move on)

A connection costs a few kilobytes of state instead of an entire thread. That single design decision is the answer to the C10k problem — and the reason Nginx stays calm under load that would topple a thread-per-request server.

The trade-off: because everything shares the worker’s event loop, blocking is poison. One slow synchronous operation would freeze every other connection on that worker. Nginx avoids this with kernel-assisted file serving (sendfile), thread pools for disk I/O, and a relentless focus on never blocking the loop.

Master and Workers

Start Nginx and you get one master process and several worker processes:

nginx: master process     # privileged: reads config, binds ports, supervises
  ├─ nginx: worker process   # unprivileged: handles real traffic
  ├─ nginx: worker process
  └─ nginx: worker process

The master runs as root just long enough to read the config and bind to privileged ports like 80 and 443, then hands all actual traffic to unprivileged workers. This split is also what makes zero-downtime reloads possible. When you run nginx -s reload, the master validates the new config, spawns fresh workers with it, and tells the old workers to stop accepting new connections and gracefully finish the ones in flight before exiting. No dropped requests.

nginx -t            # validate config syntax BEFORE applying it
nginx -s reload     # graceful, zero-downtime reload
nginx -T            # dump the full effective config (all includes merged)

Get into the habit of running nginx -t before every reload. A typo caught here is harmless; the same typo applied live is an outage.

Config Anatomy

Nginx configuration is a hierarchy of blocks (contexts) and directives. Here is a minimal but complete file:

user www-data;
worker_processes auto;          # one worker per CPU core

events {
    worker_connections 1024;    # max connections per worker
}

http {
    include       /etc/nginx/mime.types;
    sendfile      on;           # kernel zero-copy file serving
    keepalive_timeout 65;

    server {
        listen 80;
        server_name example.com;

        location / {
            root  /var/www/html;
            index index.html;
        }
    }
}

A server block is a virtual host; a location block decides how a particular URL path is handled. Almost everything interesting happens inside location.

How Nginx Picks a Location (the part that trips everyone up)

When a request arrives, Nginx doesn’t just grab the first matching location. It follows a precise order of precedence:

  1. An exact match (location = /path) wins immediately if it matches.
  2. Otherwise Nginx scans all prefix matches and remembers the longest one — but doesn’t stop yet.
  3. If that longest prefix used the ^~ modifier, it stops there and skips regex entirely.
  4. Otherwise it checks regular-expression locations (~ case-sensitive, ~* case-insensitive) in the order they appear; the first match wins.
  5. If no regex matches, it falls back to the longest prefix from step 2.
location = / { ... }              # ONLY the exact "/"
location / { ... }               # catch-all prefix
location ^~ /assets/ { ... }     # prefix; "don't bother checking regex"
location ~* \.(jpg|png|css|js)$ { ... }   # case-insensitive regex

The practical takeaway: a regex location can “steal” requests you expected a prefix to handle. Use ^~ on directories you want served directly (like static assets) so a broad regex never hijacks them.

The Most Common Modern Role: Reverse Proxy

These days Nginx most often sits in front of an application server — Node, Python, Go, PHP — handling the public-facing HTTP and forwarding requests inward. The critical detail is passing the real client information along, since your app now sees Nginx as its “client”:

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

    location / {
        proxy_pass http://127.0.0.1:3000;

        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;
    }
}

Load Balancing Across Backends

Define an upstream pool and Nginx will distribute traffic across every healthy backend, with passive health checks and automatic failover built in:

upstream backend {
    least_conn;                          # route to the least-busy server
    keepalive 32;                        # reuse TCP connections to backends

    server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.3:3000 backup;         # only used if the others are down
}

server {
    location / {
        proxy_pass http://backend;

        # required for upstream keepalive to actually work:
        proxy_http_version 1.1;
        proxy_set_header Connection "";

        proxy_next_upstream error timeout http_502 http_503;  # retry elsewhere on failure
    }
}

Balancing strategies include round-robin (the default), least_conn, and ip_hash for simple session stickiness. The keepalive + proxy_http_version 1.1 + cleared Connection header trio is one you must set together, or every proxied request re-dials a fresh TCP connection to your backend.

HTTPS, Done Properly

Terminate TLS at Nginx, redirect all plain HTTP to HTTPS, and pair it with a free auto-renewing certificate from Let’s Encrypt (certbot --nginx):

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

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # tell browsers to always use HTTPS for this domain
    add_header Strict-Transport-Security "max-age=63072000" always;

    # reuse TLS sessions to avoid expensive repeat handshakes
    ssl_session_cache shared:SSL:10m;

    location / {
        root  /var/www/example;
        index index.html;
    }
}

Caching: Take Load Off Your App

Nginx can cache backend responses and serve them itself, dramatically reducing load. The advanced version controls the cache key, protects against stampedes, and lets logged-in users bypass the cache:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:50m
                 max_size=5g inactive=24h use_temp_path=off;

server {
    location /api/ {
        proxy_cache api_cache;
        proxy_cache_valid 200 10m;

        # cache-stampede protection: only ONE request refreshes an expired
        # entry while everyone else is served the stale copy meanwhile
        proxy_cache_lock on;
        proxy_cache_use_stale error timeout updating;
        proxy_cache_background_update on;

        # don't cache logged-in users
        proxy_cache_bypass $cookie_session $http_authorization;
        proxy_no_cache     $cookie_session $http_authorization;

        add_header X-Cache-Status $upstream_cache_status;   # HIT / MISS / STALE
        proxy_pass http://backend;
    }
}

The proxy_cache_lock and proxy_cache_background_update pair is what prevents a “thundering herd” — the moment a popular cached URL expires and a flood of simultaneous requests all miss the cache and hammer your backend at once.

Rate Limiting

Protect login endpoints and APIs from abuse by capping request rates per client, while still allowing brief legitimate bursts:

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=mylimit burst=20 nodelay;
        proxy_pass http://backend;
    }

    location /login {
        limit_req zone=mylimit burst=5;     # stricter on auth
        proxy_pass http://backend;
    }
}

Single-Page Apps and Static Sites

A SPA needs every unknown route to fall back to index.html so client-side routing works, while still serving real files when they exist — that’s exactly what try_files does:

server {
    root /var/www/spa/dist;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;   # the SPA fallback line
    }

    # fingerprinted assets can be cached aggressively
    location ~* \.(js|css|png|jpg|svg|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Going Further: Nginx as an API Gateway

One of Nginx’s most powerful features is auth_request, which makes an internal subrequest to an authentication service before serving the real request. If the auth service returns a success status, the request proceeds; otherwise it’s rejected — letting you centralize JWT or API-key checks in one small service:

location /api/ {
    auth_request /_auth;            # verify first
    proxy_pass http://backend;
}

location = /_auth {
    internal;                       # not reachable from outside
    proxy_pass http://auth-service/verify;
    proxy_pass_request_body off;
}

Layer that with rate limiting, map-based routing, and caching, and Nginx becomes a legitimate, fast API gateway with no extra moving parts. For logic beyond what directives express, you can even script the request lifecycle with njs (Nginx’s own JavaScript subset) or with Lua via OpenResty — the foundation behind large-scale gateways like Kong.

Tuning and Debugging Like You Mean It

A few directives deliver outsized performance wins in practice: TLS session caching (handshakes are expensive), upstream keepalive (stop re-dialing your backend), and open_file_cache for static-heavy sites. And when something behaves mysteriously, a custom log format turns your access log into a profiler:

log_format timed '$remote_addr "$request" $status '
                 'rt=$request_time '            # total time Nginx spent
                 'urt=$upstream_response_time'; # time the backend took

access_log /var/log/nginx/access.log timed;

Now you can answer the question that matters during an incident: is it slow because of my app or because of Nginx? If rt is high but urt is low, the time is in Nginx, the network, or the client — not your backend.

The Takeaway

Nginx rewards understanding. Its speed isn’t magic — it’s the direct result of an event-driven architecture that refuses to block. Its configuration isn’t arbitrary — it’s a request pipeline with a predictable order. Once you can picture a request flowing through workers, matching a location, and being proxied, cached, or rate-limited along the way, the config stops being a black box you inherited and becomes a tool you can reach for deliberately. Start with nginx -T to see what you’re really running, and build from there.

Source: nginx.org — official documentation

Have a project in mind?

The same team behind these articles builds production platforms every day. Tell us what you're working on.

Let's connect [email protected]