Skip to content

Portal | Level: L1: Foundations | Topics: Nginx & Web Servers, Load Balancing, TLS & PKI | Domain: DevOps & Tooling

Nginx & Web Servers - Primer

Why This Matters

Nginx sits in front of almost everything. It handles HTTP, terminates TLS, proxies to backends, serves static files, rate limits traffic, and does it all with a tiny memory footprint. Whether you are running a three-tier web app or a Kubernetes ingress, understanding Nginx is not optional.

The problem is that Nginx configuration looks deceptively simple but has deep subtleties — location matching order, the infamous proxy_pass trailing slash, and the "if is evil" trap. This primer walks you through how Nginx actually processes requests, so you can configure it with confidence instead of trial and error.


Architecture: How Nginx Works

Nginx uses an event-driven, non-blocking architecture:

                 ┌─────────────────┐
  Clients ──────▶│  Master Process  │  (reads config, manages workers)
                 └────────┬────────┘
                          │ fork
           ┌──────────────┼──────────────┐
           ▼              ▼              ▼
      ┌─────────┐   ┌─────────┐   ┌─────────┐
      │ Worker 1 │   │ Worker 2 │   │ Worker N │
      │ (event   │   │ (event   │   │ (event   │
      │  loop)   │   │  loop)   │   │  loop)   │
      └─────────┘   └─────────┘   └─────────┘

Each worker handles thousands of connections using epoll/kqueue. No thread-per-connection overhead. This is why Nginx can handle 10K+ concurrent connections on modest hardware.

Who made it: Nginx was created by Igor Sysoev in 2004 to solve the C10K problem (handling 10,000 concurrent connections) for Rambler, Russia's second-largest website. The name is pronounced "engine-X." F5 Networks acquired Nginx Inc. in 2019 for $670 million.

Key Config: Worker Tuning

# /etc/nginx/nginx.conf

worker_processes auto;          # one per CPU core (auto detects)
worker_rlimit_nofile 65535;     # max open files per worker

events {
    worker_connections 4096;    # max connections per worker
    multi_accept on;            # accept all pending connections at once
    use epoll;                  # Linux: use epoll (default on Linux)
}

Total max connections = worker_processes x worker_connections.


Directive Hierarchy

Nginx config is hierarchical. Directives inherit from parent contexts:

main (global)
├── events { }
├── http { }
│   ├── server { }         # virtual host
│   │   ├── location / { } # URI matching
│   │   └── location /api { }
│   └── server { }         # another virtual host
└── stream { }             # TCP/UDP proxy (non-HTTP)

A directive set in http {} applies to all server {} blocks unless overridden. A directive in server {} applies to all location {} blocks unless overridden.

http {
    gzip on;                    # applies to all servers

    server {
        gzip off;               # overrides for this server only

        location /api {
            gzip on;            # overrides again for this location
        }
    }
}

Server Blocks (Virtual Hosts)

server {
    listen 80;
    server_name example.com www.example.com;
    root /var/www/example;

    location / {
        try_files $uri $uri/ =404;
    }
}

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

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

How Nginx Selects a Server Block

  1. Match the listen directive (IP:port)
  2. Match server_name against the Host header
  3. If no match, use the default_server
server {
    listen 80 default_server;    # catches unmatched requests
    server_name _;               # convention for "match nothing"
    return 444;                  # close connection (Nginx special)
}

Location Matching

This is where most Nginx confusion lives. Nginx evaluates locations in a specific order:

Modifier Type Priority Example
= Exact match 1 (highest) location = /health
^~ Prefix (no regex) 2 location ^~ /static/
~ Regex (case-sensitive) 3 location ~ \.php$
~* Regex (case-insensitive) 3 location ~* \.(jpg|png)$
(none) Prefix 4 (lowest) location /

The Matching Algorithm

  1. Nginx first finds the longest matching prefix location
  2. If that prefix has ^~, stop — use it
  3. If that prefix is = (exact), stop — use it
  4. Otherwise, evaluate all regex locations in config order; first regex match wins
  5. If no regex matches, use the longest prefix from step 1
# This is evaluated in this order:
location = /exact { }          # only matches /exact (not /exact/)
location ^~ /static/ { }      # prefix, skips regex check
location ~ \.php$ { }         # regex, checked in config order
location ~* \.(jpg|gif)$ { }  # case-insensitive regex
location /general/ { }        # prefix, lowest priority
location / { }                # catch-all prefix

Reverse Proxy with proxy_pass

Basic Proxy

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

The Trailing Slash Matters

# NO trailing slash: location path is APPENDED to backend URL
location /app/ {
    proxy_pass http://backend;
    # Request: /app/page -> Backend sees: /app/page
}

# WITH trailing slash: location path is REPLACED
location /app/ {
    proxy_pass http://backend/;
    # Request: /app/page -> Backend sees: /page
}

# With a path: location is replaced with the path
location /app/ {
    proxy_pass http://backend/v2/;
    # Request: /app/page -> Backend sees: /v2/page
}

This is a top-5 Nginx misconfiguration. Get it wrong and your backend receives mangled paths.

Remember: Trailing slash mnemonic: No slash = No change (path passes through), Slash = Substitute (location prefix is replaced). The trailing / on proxy_pass activates URI rewriting.


Upstream Blocks (Load Balancing)

upstream backend {
    least_conn;                  # load balancing method
    server 10.0.0.1:8080 weight=3;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080 backup; # only used when others are down

    keepalive 32;                # persistent connections to backends
}

server {
    location / {
        proxy_pass http://backend;
        proxy_http_version 1.1;
        proxy_set_header Connection "";  # required for keepalive
    }
}

Load Balancing Methods

round_robin     default, rotate through servers
least_conn      send to server with fewest active connections
ip_hash         sticky sessions based on client IP
hash $key       consistent hashing on arbitrary key
random          random with optional two-choice algorithm

Health Checks (Open Source)

Nginx OSS does passive health checks only:

upstream backend {
    server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
}

After 3 failures in 30 seconds, the server is marked down for 30 seconds.


SSL/TLS Termination

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

    ssl_certificate     /etc/letsencrypt/live/secure.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/secure.example.com/privkey.pem;

    # Modern TLS config
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    ssl_prefer_server_ciphers off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;

    # Session caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # HSTS
    add_header Strict-Transport-Security "max-age=63072000" always;
}

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

Caching

Proxy Cache

http {
    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m
                     max_size=1g inactive=60m use_temp_path=off;

    server {
        location / {
            proxy_cache my_cache;
            proxy_cache_valid 200 302 10m;
            proxy_cache_valid 404      1m;
            proxy_cache_use_stale error timeout updating http_500 http_502;
            add_header X-Cache-Status $upstream_cache_status;

            proxy_pass http://backend;
        }
    }
}

Static File Caching Headers

location ~* \.(css|js|jpg|png|gif|ico|woff2)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
    access_log off;
}

Rate Limiting

http {
    # Define rate limit zone: 10 requests/second per IP
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

    server {
        location /api/ {
            limit_req zone=api burst=20 nodelay;
            # burst=20: allow 20 request burst
            # nodelay: don't delay, just allow burst then reject
            limit_req_status 429;

            proxy_pass http://backend;
        }
    }
}

Common Patterns

try_files for SPAs

location / {
    root /var/www/app;
    try_files $uri $uri/ /index.html;
}

Custom Error Pages

error_page 502 503 504 /50x.html;
location = /50x.html {
    root /var/www/errors;
    internal;
}

WebSocket Proxy

location /ws/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400;
}

Security Headers

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'" always;

Testing and Reloading

# Test config syntax BEFORE reloading
nginx -t

# Reload (graceful — no downtime)
nginx -s reload
# or
systemctl reload nginx

# Restart (drops connections — avoid in production)
systemctl restart nginx

# Show compiled modules and config path
nginx -V

Always nginx -t before nginx -s reload. Always. Make it muscle memory.

Under the hood: nginx -s reload sends SIGHUP to the master process. The master forks new worker processes with the new config, then gracefully shuts down old workers after they finish serving active requests. This is why reload is zero-downtime but restart (systemctl restart nginx) drops connections — restart does a hard stop/start.

Debug clue: When Nginx returns a 502 Bad Gateway, it means Nginx is running fine but cannot connect to the upstream backend. Check: is the backend process running? Is it listening on the right port? Is there a firewall rule blocking the connection? The answer is almost always in the Nginx error log at /var/log/nginx/error.log.


Wiki Navigation

Prerequisites