The web server, reverse proxy, and load balancer that fronts a huge share of the internet. You'll serve static files, reverse-proxy an app, terminate TLS, load-balance a pool, cache responses, and harden it for production — every step runnable locally. Work top to bottom, or jump to a part.
The journey:
| You need | Why |
|---|---|
| Docker (or a local Nginx install) | The examples run Nginx in a container |
| A terminal + text editor | You'll edit nginx.conf by hand |
| Basic HTTP familiarity | Requests, headers, status codes come up throughout |
Confirm it runs: docker run --rm -p 8080:80 nginx:1.27 then open http://localhost:8080 — you'll see the welcome page.
Nginx is an event-driven reverse proxy that does three jobs, often at once:
| Role | What it does |
|---|---|
| Web server | Serves static files (HTML, JS, images) fast |
| Reverse proxy | Forwards requests to backend apps and returns their responses |
| Load balancer | Spreads requests across a pool of backends |
Its power comes from an asynchronous, event-loop architecture: a few worker processes handle thousands of concurrent connections without a thread per request. You configure it declaratively in nginx.conf, then reload — no restart needed.
Mount a directory of files and serve them:
mkdir site && echo "<h1>Hello from Nginx</h1>" > site/index.html
docker run --rm -p 8080:80 -v "$(pwd)/site:/usr/share/nginx/html:ro" nginx:1.27
Open http://localhost:8080. Nginx maps the URL path to files under its root. A minimal server for static content:
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404; # serve the file, or a dir index, or 404
}
}
try_files is the workhorse: try the exact URI, then a directory, then fall back — this is also how single-page apps route (try_files $uri /index.html;).
nginx.conf is a tree of contexts (blocks) containing directives (settings). The hierarchy:
worker_processes auto; # main context
events { worker_connections 1024; }
http { # http context
include mime.types;
sendfile on;
server { # a virtual host
listen 80;
server_name app.example.com;
location /api/ { # a route
proxy_pass http://backend;
}
}
}
server blocks.listen + server_name.Validate and reload without dropping connections:
nginx -t # test config syntax
nginx -s reload # graceful reload (workers finish in-flight requests)
Always nginx -t before reloading — a bad reload keeps the old config running, but a bad restart takes you down.
The most common production role: sit in front of an app server and forward requests.
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:3000; # your app
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;
}
}
Those proxy_set_header lines matter: without them the backend sees Nginx's IP and loses the original host/scheme, breaking redirects, logging, and rate limiting. X-Forwarded-For and X-Forwarded-Proto are how the app learns the real client and whether the original request was HTTPS.
Nginx terminates TLS so your app speaks plain HTTP internally. Redirect all HTTP to HTTPS, and serve TLS on 443:
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri; # force HTTPS
}
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; # no legacy TLS
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Use Let's Encrypt (via certbot) for free, auto-renewing certificates. Disable TLS 1.0/1.1, keep TLS 1.3 on, and enable HTTP/2 for multiplexing.
Define an upstream pool and proxy to it — Nginx spreads requests across the members:
upstream backend {
least_conn; # send to the least-busy server
server 10.0.0.1:3000;
server 10.0.0.2:3000;
server 10.0.0.3:3000 backup; # only used if others are down
}
server {
listen 80;
location / {
proxy_pass http://backend;
}
}
Load-balancing methods: round-robin (default), least_conn (fewest active connections — good for uneven request times), ip_hash (sticky by client IP). Add health via max_fails / fail_timeout so a dead backend is taken out of rotation automatically.
Cache upstream responses to cut backend load, and compress text to cut bandwidth:
# Compression (in http context)
gzip on;
gzip_types text/plain text/css application/json application/javascript;
# Proxy cache (declare a zone in http, use it in a location)
proxy_cache_path /var/cache/nginx keys_zone=app_cache:10m max_size=1g inactive=60m;
server {
location / {
proxy_cache app_cache;
proxy_cache_valid 200 10m; # cache 200s for 10 minutes
add_header X-Cache-Status $upstream_cache_status; # HIT / MISS
proxy_pass http://backend;
}
}
The X-Cache-Status header lets you confirm HITs. Static assets get long expires headers; dynamic responses cache briefly with a sensible proxy_cache_valid.
Protect backends from abuse and harden responses:
# Rate limit zone (http context): 10 req/s per client IP
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
# Security headers
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location /api/ {
limit_req zone=api burst=20 nodelay; # allow short bursts
proxy_pass http://backend;
}
client_max_body_size 10m; # cap upload size (avoid huge-body DoS)
}
limit_req with a burst smooths spikes without rejecting legitimate traffic. Set client_max_body_size deliberately — the default rejects large uploads with 413.
nginx -t before every reload, and reload (not restart) for zero-downtime config changes.Host and X-Forwarded-* headers to backends.proxy_connect_timeout, proxy_read_timeout) so a slow backend can't pile up connections.client_max_body_size) and rate-limit public endpoints.worker_processes auto and worker_connections for your CPU/traffic.proxy_set_header Host/X-Forwarded-*. The backend then logs Nginx's IP and builds wrong redirect URLs.restart instead of reload. A restart drops connections; reload is graceful. And always nginx -t first.add_header inside if/without always. add_header doesn't inherit into nested blocks and is skipped on error responses unless you add always.client_max_body_size. Uploads fail with a confusing 413, or huge bodies exhaust memory.try_files missing for SPAs. Deep links 404 without try_files $uri /index.html;.try_files $uri /index.html; and confirm a deep link like /dashboard still loads.:3000, proxy / to it with the full X-Forwarded-* header set, and confirm the app sees your real IP.return 301 to HTTPS. Verify with curl -I http://localhost.upstream with least_conn, and watch requests spread. Kill one and confirm max_fails routes around it.X-Cache-Status header; hit an endpoint twice and watch MISS→HIT.Self-check:
Host and X-Forwarded-For to a backend?reload vs restart — which is zero-downtime, and what do you run first?least_conn over round-robin?You now have the full loop: serve → proxy → TLS → load-balance → cache → protect → harden. That's Nginx as it actually fronts production.