Learn Nginx in a Single Post: Complete Tutorial From Configuration to Reverse Proxy, TLS, and Production

Nginx is the web server / reverse proxy that runs in front of nearly every production web application — it terminates TLS, load-balances across backend instances, caches responses, compresses output, rate-limits, and serves static files faster than any application server. It handles tens of thousands of concurrent connections with a tiny memory footprint. This single post covers the whole server in five stages, with hand-drawn diagrams and runnable config.

Learning Roadmap

Nginx Roadmap

The roadmap moves from the config structure (Stage 1), through static files (Stage 2), the reverse proxy + load balancing (Stage 3), TLS/HTTPS (Stage 4), and the production layer (Stage 5).


Stage 1 — Configuration: Nested Blocks + Directives

The config hierarchy

Nginx config is a tree of nested blocks, each with directives (key-value settings). The blocks nest: httpserverlocation. Inner directives override outer ones.

Nginx Config: Nested Blocks + Directives

# /etc/nginx/nginx.conf
events {
    worker_connections 1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    upstream backend {
        server app1:3000;
        server app2:3000;
    }

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

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

        location /api/ {
            proxy_pass http://backend;
        }
    }
}
Block Scope
http {} global HTTP settings (mime types, gzip, logging)
server {} one virtual host (a site); listens on a port + responds to server_name
location {} one URL pattern within a server; routes to files or a proxy
upstream {} a named pool of backend servers for load balancing
Directive What it does
listen port/socket to listen on (listen 80; listen 443 ssl;)
server_name hostnames this server block responds to
root filesystem root for static files
proxy_pass forward requests to an upstream URL
return redirect (return 301 https://...) or status code
try_files fallback chain (try_files $uri $uri/ =404)
include pull in another config file

Pitfall: include mime.types; inside http {} maps file extensions to Content-Type headers. Without it, CSS/JS/images are served as application/octet-stream (downloaded instead of rendered). Always include it.


Stage 2 — Static File Serving

root vs alias

location /static/ {
    root /var/www/site;   # file: /var/www/site/static/style.css
}

location /assets/ {
    alias /var/www/site/assets/;   # file: /var/www/site/assets/style.css (strips /assets/)
}

root appends the full URI to the root path; alias replaces the location prefix with the alias path. root is the common choice (simpler); alias when the filesystem path doesn’t match the URL.

try_files — the fallback chain

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

try_files checks each option in order: $uri (the exact file), $uri/ (a directory), then /index.html (a fallback). This is how you serve a SPA (React/Vue/Next.js static export) — any unmatched URL falls back to index.html so the client-side router takes over.

Performance for static files

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

Set long cache lifetimes on static assets with content-hashed filenames (app.3a9f.js) so new deploys are new URLs — the browser caches aggressively, never needs invalidation.


Stage 3 — Reverse Proxy + Load Balancing

proxy_pass — forward to a backend

Reverse Proxy + Upstream + Load Balancing

upstream backend {
    server app1:3000;
    server app2:3000;
    server app3:3000;
}

server {
    listen 80;
    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_pass http://backend; forwards the request to the upstream pool. The proxy_set_header directives pass the original client’s IP (not nginx’s IP) so the backend sees who the real client is. Without them, every request looks like it comes from nginx (127.0.0.1 or the docker network).

Load balancing methods

Method How Use
round_robin (default) distribute evenly uniform backends
least_conn fewest active connections uneven request costs
ip_hash same client → same backend (sticky) session affinity without cookies
random random pick simple + low overhead
upstream backend {
    least_conn;
    server app1:3000 weight=3;   # 3x more traffic
    server app2:3000;
    server app3:3000 backup;       # only if others fail
}

Health checks + failover

upstream backend {
    server app1:3000 max_fails=3 fail_timeout=30s;
    # if app1 fails 3 times in 30s, it's removed from rotation for 30s
}

Nginx (open source) does passive health checks — it removes a backend if it fails (5xx or timeout) max_fails times in fail_timeout seconds. Nginx Plus (paid) adds active health checks.

WebSocket proxying

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

WebSockets need HTTP/1.1 + Upgrade/Connection headers. Without these, the upgrade handshake fails and the WebSocket connection is refused.

Pitfall: Forgetting proxy_set_header Host $host; — the backend sees backend (the upstream name) instead of the real hostname, breaking virtual-host-based routing and CORS. Always pass Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto.


Stage 4 — TLS: HTTPS, Let’s Encrypt, HTTP/2

Enable HTTPS

server {
    listen 443 ssl;
    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;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_session_cache    shared:SSL:10m;
    ssl_session_tickets off;
}

Redirect HTTP → HTTPS

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Let’s Encrypt (certbot)

sudo certbot certonly --webroot -w /var/www/site -d example.com -d www.example.com
# generates certs in /etc/letsencrypt/live/example.com/
# auto-renews (certbot installs a systemd timer)

HTTP/2

listen 443 ssl http2;   # one directive, enables multiplexed connections

HTTP/2 multiplexes multiple requests over one TCP connection (faster than HTTP/1.1’s one-request-per-connection). Nginx enables it with one directive.

Security headers

add_header Strict-Transport-Security "max-age=31557600; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

Strict-Transport-Security (HSTS) tells the browser to always use HTTPS (even if the user types http://). always ensures the header is sent even on error responses.

Pitfall: add_header in a location block replaces (not extends) headers from the server block. If you add a header in a location, you must re-declare all headers from the server block. Use add_header ... always; on each.


Stage 5 — Production: Caching, Gzip, Rate Limiting

proxy_cache — cache upstream responses

Production: Caching, Gzip, Rate Limit, Security

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m max_size=1g inactive=10m;

server {
    location /api/ {
        proxy_pass http://backend;
        proxy_cache api_cache;
        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 1m;
        add_header X-Cache-Status $upstream_cache_status;   # HIT / MISS / BYPASS
    }
}

Nginx can cache upstream responses on disk — the second request for the same URL is served from the cache (no backend call). $upstream_cache_status tells you whether the response was a cache hit or miss (useful for debugging).

Gzip compression

gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on;

Compresses text responses (HTML, CSS, JS, JSON) — cuts bandwidth by 60-80%. gzip_types controls which Content-Types are compressed (don’t compress images; they’re already compressed).

Rate limiting

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

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

limit_req_zone defines a rate limit (10 requests/second per IP); limit_req applies it with a burst capacity of 20 (spike allowed) and nodelay (serve burst immediately, not queued). Excess requests get a 503 Service Unavailable.

Zero-downtime reload

nginx -t          # test config (always do this first)
nginx -s reload   # reload config without dropping connections

nginx -s reload sends a SIGHUP; the master process starts new workers with the new config while old workers finish their active connections and exit. Zero downtime — no dropped requests. Always run nginx -t first to catch syntax errors (a bad config reloaded mid-request can cause a brief 500).

Other production settings

server_tokens off;                          # hide nginx version (security)
client_max_body_size 10m;                    # max upload size
keepalive_timeout 65;                        # reuse connections (perf)
proxy_read_timeout 60s;                       # wait for slow upstream
proxy_next_upstream error timeout http_502;  # retry on failure

Quick-Start Checklist

  1. Install Nginxapt install nginx / brew install nginx / Docker nginx:alpine.
  2. Edit nginx.conf — add one server block with listen 80 + a location / that serves a static file.
  3. nginx -t && nginx -s reload — test + reload (zero downtime).
  4. Add a reverse proxyupstream backend { server app:3000; } + proxy_pass http://backend;.
  5. Pass headersproxy_set_header Host / X-Real-IP / X-Forwarded-For / X-Forwarded-Proto.
  6. Enable HTTPSlisten 443 ssl; + cert paths + return 301 https://... redirect.
  7. Run certbotsudo certbot certonly --webroot -w /var/www/site -d your.domain.
  8. Enable HTTP/2listen 443 ssl http2; (one word).
  9. Add gzip + caching + rate limiting — the production directives above.
  10. Reload, don’t restartnginx -s reload never drops connections.

Common Pitfalls

  • Missing proxy_set_header Host $host; — backend sees the upstream name, not the real hostname; breaks virtual hosting and CORS.
  • root vs alias confusionroot appends the URI; alias replaces the prefix. Getting them wrong serves the wrong file or 404s.
  • Forgetting include mime.types; — CSS/JS served as application/octet-stream → browser downloads them instead of rendering.
  • add_header in location overrides server — must re-declare all headers; they don’t inherit.
  • Not running nginx -t before reload — a syntax error in a reload can cause a brief 500 (old workers serve stale config; new workers fail).
  • Compressing already-compressed files — gzip on images/PDFs wastes CPU; only compress text.
  • No WebSocket headersUpgrade + Connection: upgrade required, else WebSocket fails silently.
  • server_tokens on; — leaks your nginx version to attackers (fingerprinting). Turn it off.

Further Reading

Nginx is the edge layer of the web stack — these PyShine tutorials connect to it:


Nginx is the swiss-army knife of web infrastructure: reverse proxy, load balancer, TLS terminator, cache, compressor, rate limiter, and static file server — all from one config file. The five stages here — config, static files, reverse proxy, TLS, production — cover everything from serving one HTML file to a TLS-terminated, load-balanced, cached, gzip-compressed, rate-limited production edge. The two habits that pay off: always pass Host + X-Forwarded-For (the backend needs to know who the real client is), and always run nginx -t before nginx -s reload (a syntax error should never reach a live reload). Install Nginx, write a 10-line config, proxy to a backend, and curl through it — once you’ve seen the proxy headers arrive at the backend, the model clicks.

Watch PyShine on YouTube

Contents