Warming up the neural circuits...
By the end of this chapter you will:
Nginx isn't a web server — it's a traffic conductor, a bouncer, a translator, and a shield. It sits between the chaos of the internet and your application, speaking HTTP so your app can focus on business logic instead of connection management.
You arrive at a five-star hotel in a city you've never visited. The entrance has a concierge desk. You don't walk past it into the kitchen to order room service. You don't wander into the laundry room to ask for fresh towels. You talk to the concierge.
The concierge does several things at once:
Now imagine 10,000 guests arriving simultaneously. Without the concierge system, they'd flood the kitchen, block the hallways, and the chef would collapse from answering "where's the bathroom?" instead of cooking. This is what happens when you expose a Node.js process directly to the internet on port 3000.
Nginx is that concierge — and in this chapter, you'll learn to configure it like a five-star establishment.
Nginx configuration is organized hierarchically. Understanding this hierarchy is the difference between copy-pasting Overflow snippets and actually owning your config.
# Main context — global settings (worker_processes, error_log, pid)
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
# Events context — connection processing model
events {
worker_connections 1024;
use epoll; # Linux's efficient I/O event notification
multi_accept on; # Accept all new connections at once, not one-by-one
}
# HTTP context — everything HTTP-related lives here
http {
include
Directives cascade downward: http → server → location. A directive set at the http level applies to all servers unless overridden. Array-type directives (like add_header) do NOT inherit — setting one in a child context replaces ALL array directives from the parent. This trips up everyone at least once.
A reverse proxy accepts client requests and forwards them to backend servers. The client thinks it's talking to the origin server — it has no idea there's a proxy in the middle. This is the fundamental pattern that enables everything else: load balancing, TLS termination, caching, .
server {
listen 80;
server_name api.yourapp.com;
location /api/ {
# The magic line: forward to your Node process
proxy_pass http://localhost:3000;
# CRITICAL: Pass the real client IP, not Nginx's IP
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
If you forget proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for, your application sees Nginx's IP (127.0.0.1 or the server's private IP) for every request. Your rate limiter thinks one IP is making all requests. Your audit logs show every action coming from the same source. Your geo-IP routing breaks. This is the single most common production Nginx mistake — missing proxy headers.
When you have multiple instances of your application, Nginx distributes traffic across them:
upstream app_backend {
# Least connections — best for variable-duration requests
least_conn;
# Backend servers with weights (4x more traffic to the bigger server)
server 10.0.1.10:3000 weight=4 max_fails=3 fail_timeout=30s;
server 10.0.1.11:3000 weight=2 max_fails=3 fail_timeout=30s;
server 10.0.1.12:3000 weight=2 max_fails=3 fail_timeout
max_fails=3 fail_timeout=30s means: if a backend fails 3 health checks within 30 seconds, mark it unhealthy for 30 seconds. After 30 seconds, try again. This prevents a flapping backend from getting a trickle of traffic that all fails — better to fail fast and route around it.
Your Node app speaks plain HTTP on localhost. Nginx handles the HTTPS heavy lifting:
server {
listen 443 ssl http2;
server_name api.yourapp.com;
# Certificate files
ssl_certificate /etc/letsencrypt/live/api.yourapp.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.yourapp.com/privkey.pem;
# Modern TLS configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
# Session caching — massive performance win for returning visitors
ssl_session_cache shared:SSL:10m;
ssl_session_timeout
Node.js serving static files is like using a Ferrari to haul furniture. Nginx serves static files at near line-speed using sendfile():
server {
listen 80;
server_name cdn.yourapp.com;
root /var/www/static;
# Cache static assets aggressively in the browser
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off; # Don't log static file requests
}
# HTML — shorter cache, always revalidate
location ~* \.html$ {
# Define rate limit zones in http context
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
limit_req_zone $http_x_api_key zone=per_key:10m rate=100r/s;
# Limit concurrent connections per IP
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
server {
location /api/ {
# 30 req/s per IP, burst of 20 queued (nodelay = process immediately up to burst)
limit_req zone=api_limit burst=20 nodelay;
limit_req_status 429;
# Max 10 concurrent connections per IP
WebSockets need special handling — the HTTP connection must be upgraded to a persistent bidirectional channel:
server {
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
# These two headers are MANDATORY for WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Long timeout — WebSockets are long-lived
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host
If your proxy_read_timeout is 60s (the default), Nginx will close idle connections after 60 seconds of inactivity. Your chat app will mysteriously disconnect users every minute. Set it to hours, not seconds, for WebSocket endpoints.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6; # Sweet spot between CPU and compression ratio
gzip_min_length 256; # Don't bother compressing tiny responses
gzip_types
text/plain
text/css
text/xml
application/json
application/javascript
application/xml+rss
image/svg+xml;Cloudflare's entire business is a reverse proxy. Every request to a Cloudflare-protected site hits their edge before reaching the origin server. Their architecture reveals patterns you can apply at any scale:
Anycast networking meets Nginx: Cloudflare runs Nginx (heavily customized) across 330+ data centers. They announce the same IP address from every location via BGP Anycast — the internet's routing protocol automatically sends each user to the nearest data center. This is Layer 3.5 load balancing that absorbs DDoS attacks before they reach your server.
Defense in depth: Before a request reaches any customer's origin server, it passes through: DDoS mitigation (dropping volumetric attacks at the network edge) → WAF (blocking injection, XSS) → Bot management → Rate limiting → lookup → Origin request. Each layer is independent — if caching is down, the WAF still runs.
The lesson for your Nginx config: Structure your config in layers. Place rate limiting before proxy_pass. Add security headers at the server level. Cache static assets aggressively. Each directive is a layer in your defense. A single monolithic location block is a single point of failure.
Netlify takes a similar approach for static sites: their edge (also heavily Nginx-based) handles atomic deploys, instant rollbacks, and per-deploy preview URLs — all through intelligent reverse proxy configuration. The pattern is always the same: push complexity to the edge, keep your application simple.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
Forgetting proxy_set_header directives | Your app sees 127.0.0.1 for every request. Rate limiting, audit logs, and geo-routing break | Always set X-Real-IP, X-Forwarded-For, X-Forwarded-Proto, and Host headers |
Using if inside location blocks | Nginx if is NOT a normal if-statement — it's part of the rewrite module and has bizarre side effects with other directives | Use map for conditional logic, try_files for file existence checks, or split into separate location blocks |
Setting proxy_read_timeout too low for streaming/upload endpoints | File uploads or SSE streams timeout mid-transfer, returning 504 errors to confused users | Set timeouts per-location: 30s for normal APIs, 300s for uploads, 3600s for WebSockets |
Not configuring keepalive to upstream | Every proxied request opens a new TCP connection. At 1000 req/s, that's 1000 TCP handshakes per second | Set keepalive 32 (or higher) in upstream blocks and proxy_http_version 1.1 with proxy_set_header Connection "" |
Using root instead of in location blocks |
server 10.0.1.99:3000 backup;) that saves your reputation.include to split config into logical files. /etc/nginx/nginx.conf → includes /etc/nginx/conf.d/*.conf → each site gets its own file. SSL settings, rate limiting zones, and upstream definitions live in separate files. A single 500-line nginx.conf is a merge conflict waiting to happen.nginx_connections_active, nginx_connections_waiting, upstream response time per backend, 4xx/5xx rate per location, and request rate. A spike in nginx_connections_waiting means your backends can't keep up — this shows up before application-level latency metrics.nginx -t), spin up Nginx in a container with the new config, curl health endpoints, and verify response headers. A bad Nginx config is a global outage — treat it with the same rigor as application code.$request_time, $upstream_connect_time, , and in your log format. When a user reports slowness, these four numbers tell you whether the problem is in Nginx, the network, or your application.sendfile on + tcp_nopush on is the magic combo for static files. sendfile() copies data from disk to socket in kernel space without context-switching to userspace. tcp_nopush sends response headers in one packet and the file body when it's fully read. Together, they cut static file latency by 30-50%.ssl_session_cache shared:SSL:10m, returning visitors skip the full TLS handshake. Latency drops from ~100ms to ~2ms for the TLS layer. At scale, this saves millions of CPU cycles per minute.worker_processes auto detects core count. Each worker handles thousands of connections asynchronously via epoll (Linux) or kqueue (BSD/macOS). More workers than cores causes context-switching overhead with zero throughput gain.worker_connections determines your concurrency limit. With worker_connections 4096 and 4 workers, Nginx handles 16,384 simultaneous connections. For a reverse proxy (2 connections per request — client→Nginx + Nginx→backend), that's ~8,000 concurrent requests. Tune this based on your expected peak load, not idle load.127.0.0.1, not 0.0.0.0. Security groups or firewall rules should allow port 443 (and optionally 80 for redirect) from anywhere, but port 3000 only from localhost. One mistake exposes your unencrypted, un-rate-limited application to the world.server_tokens off; removes the version from error pages and the Server header. Exploit scanners target specific Nginx versions — don't hand them the information.limit_req with $binary_remote_addr not $remote_addr. $binary_remote_addr is a 4-byte (IPv4) or 16-byte (IPv6) binary representation. $remote_addr is a variable-length string (up to 45 characters for IPv6). For 100,000 unique IPs, the binary version uses 1.6MB vs 4.5MB — a 3x memory savings in the rate limit zone.Host headers to prevent cache poisoning. If your config uses $host in proxy_pass without , an attacker sending can trick your app into generating malicious links. Always set an explicit and add a default server block that rejects unknown hosts:server {
listen 80 default_server;
server_name _;
return 444; # Nginx-specific: close connection without response
}/etc/nginx/conf.d/security-headers.conf) with HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. Include it in every server block. One forgotten header on one subdomain is a vulnerability.Write your first reverse proxy config: Your Node app runs on localhost:3000. Write an Nginx server block that: listens on port 80, proxies all requests to the Node app, sets the correct proxy headers, and redirects all HTTP traffic to HTTPS. Test with curl -I http://localhost and verify the redirect.
Static file serving: Your React build output is in /var/www/frontend/build. Configure Nginx to: serve index.html for all non-file routes (SPA fallback), cache static assets (JS, , images) for 1 year with immutable directive, and deny access to .env and .git paths. Verify with curl -I http://localhost/static/js/main.js — you should see Cache-Control: public, immutable and a far-future Expires header.
Build a load-balanced setup with health checks: Create an upstream block with 3 backends (localhost:3001, localhost:3002, localhost:3003). Configure least_conn algorithm, passive health checks (max_fails=2 fail_timeout=15s), and a backup server. Write a script that kills port 3002 and verify traffic shifts to 3001 and 3003. Bring 3002 back and verify it rejoins rotation.
Rate limiting strategy: Configure three tiers of rate limiting: 60 req/min per IP for /api/public/*, 300 req/min per IP for /api/* with authenticated users, and 5 req/min per IP for /api/auth/login. Use limit_req_zone with appropriate burst values. Test with Apache Bench (ab) or wrk to verify that the 61st request to /api/public/status returns 429.
Multi-site Nginx with shared configuration: Design an Nginx configuration for a SaaS platform hosting 50 customer domains. Each domain needs: HTTPS (individual cert or wildcard), rate limiting (100 req/s per domain), custom error pages, and proxying to a shared backend. Create the config structure using include directives, map for domain-to-backend routing, and a wildcard server block. Write a script that adds a new domain by creating a single file and running nginx -t && nginx -s reload.
High-availability Nginx with keepalived: On two VPS instances, configure Nginx identically. Use keepalived to create a floating virtual IP (VIP) that moves between the two instances. The active instance holds the VIP and serves traffic. If the active instance fails (simulate with systemctl stop nginx or iptables drop), keepalived moves the VIP to the standby. Measure the failover time and describe what happens to in-flight requests. Document the tradeoffs vs. using a cloud .
Q: What does proxy_pass do in Nginx?
A: proxy_pass forwards client requests to a backend server (or upstream group). Nginx acts as an intermediary — the client connects to Nginx, Nginx connects to the backend, and Nginx relays the response back. Critically, proxy_pass with a URI path rewrites the request path. proxy_pass http://backend/api/ with request /users becomes /api/users at the backend. Without a URI path, the original request URI is preserved.
Q: Why do you need proxy_set_header directives?
A: Nginx modifies or strips certain headers when proxying. The Host header becomes the proxy_pass hostname. The client's real IP is lost — your backend sees Nginx's IP instead. proxy_set_header X-Real-IP $remote_addr and proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for pass the real client IP through. Without these, your application's IP-based features (rate limiting, audit trails, geo-routing) all break.
Q: What's the difference between root and alias in a location block?
A: appends the full request URI to the root path. with looks for . replaces the location prefix. with looks for (note: is replaced, not appended). Use when the URI path should be part of the filesystem path. Use when you want to map a URL path to a completely different directory.
Q: How does Nginx handle 10,000 concurrent connections on a single thread? A: Nginx uses an event-driven, non-blocking architecture. Instead of one thread per connection (Apache's traditional model), Nginx uses a small number of worker processes (typically one per CPU core). Each worker uses an event notification mechanism (epoll on Linux, kqueue on BSD) to monitor thousands of file descriptors simultaneously. When a socket is ready to read, the worker handles that event and moves on — it never blocks waiting for I/O. This is why Nginx can handle 10,000+ concurrent connections with a few megabytes of RAM, while thread-per-connection servers would need gigabytes. The key is that most connections are idle most of the time — waiting for the client to send the next request or for the backend to respond. Nginx doesn't dedicate resources to idle connections.
Q: You have a WebSocket application behind Nginx. Users report random disconnections every 60 seconds. What's wrong and how do you fix it?
A: The default proxy_read_timeout is 60 seconds. For WebSocket connections that are idle (no messages in either direction), Nginx closes the connection after this timeout. The fix: set proxy_read_timeout 3600s; (or higher) specifically for the WebSocket location block. Also verify that proxy_http_version 1.1; is set and the Upgrade and Connection headers are properly configured. Additionally, consider implementing application-level ping/pong (the WebSocket protocol has built-in ping/pong frames) to keep the connection active and detect dead connections faster than a 1-hour timeout.
Q: Design an Nginx configuration for a zero-downtime deployment strategy. How do you drain traffic from old instances and route to new ones?
A: The strategy uses upstream groups with health checks and connection draining. (1) Deploy new instances alongside old ones, register them in the upstream group with down initially. (2) Run health checks against new instances — mark them only when passing. (3) Mark old instances with — Nginx stops sending NEW requests to them but allows existing connections to complete. (4) Wait for (or monitor active connections via stub_status) to ensure all in-flight requests finish. (5) Remove old instances from the upstream block and reload Nginx. For a more sophisticated approach: use Nginx Plus with its for dynamic upstream reconfiguration without reload, or use a service mesh with sidecar proxies that handle draining at the instance level. The key insight: Nginx's is graceful — old worker processes finish existing requests while new workers pick up the new config. There's never a moment where traffic is dropped.
Nginx is the universal front door for backend applications. It terminates TLS, proxies requests to your Node/Python/Go backends, balances load across multiple instances, serves static files at blazing speed, and protects your application with rate limiting. The configuration model — main → events → http → server → location — is hierarchical and predictable once you understand inheritance rules. proxy_pass is the core directive; proxy_set_header is the one most people forget. Upstream blocks with least_conn, keepalive, and passive health checks create a resilient backend pool. TLS termination with session caching and OCSP stapling gives you HTTPS with near-zero overhead. Rate limiting at the edge stops abuse before it consumes application resources. WebSocket proxying needs Upgrade headers and long timeouts. Static file serving with sendfile and aggressive caching headers offloads 90% of requests from your application. Nginx sits between the chaos of the internet and the order of your backend — configure it deliberately, test it religiously, and it will protect your application for years.
proxy_pass forwards requests to backends. proxy_set_header passes real client info — never skip this.least_conn for variable workloads. keepalive prevents TCP handshake per request.sendfile on + tcp_nopush on = fastest static file serving. Let Nginx handle static assets, not your app.limit_req_zone defines limits, limit_req applies them. Use $binary_remote_addr for memory efficiency.proxy_http_version 1.1, Upgrade header, Connection "upgrade", and proxy_read_timeout 3600s.nginx -t tests config. Always test before reload. Bad config = full outage. + handles thousands of concurrent connections on minimal RAM.What directive passes the real client IP to your backend application?
A) proxy_pass_header B) proxy_set_header X-Real-IP $remote_addr C) proxy_client_ip on D) client_ip_passthrough on
An Nginx upstream block with least_conn sends requests to:
A) The server with the fastest response time B) The server with the fewest active connections C) Each server in sequence D) A random server
What happens if you forget proxy_set_header Upgrade $http_upgrade for a WebSocket location?
A) WebSockets work but slower B) The connection upgrade fails — WebSockets don't work at all C) Nginx falls back to long-polling D) Nothing — WebSockets don't need special headers
Why use $binary_remote_addr instead of $remote_addr in limit_req_zone?
A) It's more secure B) It uses less memory — 4 bytes vs up to 45 bytes per IP C) It supports IPv6 better D) It's required by newer Nginx versions
worker_processes auto sets the number of worker processes to:
A) The number of CPU cores B) Twice the number of CPU cores C) 1024 D) The number of active connections divided by 512
You run and get "test failed". What should you NOT do?
A) Read the error message carefully C) Fix the syntax error and test again D) Check the config file line mentioned in the error
root appends the full URI path. location /static/ with root /var/www looks for /var/www/static/image.png. alias /var/www/static/ looks for /var/www/static/image.png |
Use alias when the location prefix should NOT be part of the filesystem path. Use root when it should |
| Duplicating config across server blocks | Three virtual hosts with identical SSL, gzip, and proxy settings. Change one, forget the other two | Use include directives: include /etc/nginx/conf.d/common-proxy.conf; in each server block |
Running nginx -s reload without testing first | A typo in your config takes down all sites. Recovery requires SSH access during an outage | Always run nginx -t (config test) before nginx -s reload. Make it part of your CI pipeline |
$upstream_response_timeserver_namelocation /images/root /var/www/var/www/images/photo.pngaliaslocation /images/alias /var/www/photos//var/www/photos/photo.png/images/rootaliasupdownproxy_read_timeoutreloadworker_connections 4096include directives keep configs modular. One file per site. Shared files for SSL, security headers, rate limits.server_tokens off; hides version. Default server block returns 444 for unknown hosts. Defense in depth.nginx -s reload anyway — it might workAn Nginx server block with only listen 80 and no explicit server_name will:
A) Only match requests with no Host header B) Become the default server for that port — matching any Host header not matched by other server blocks C) Not match any requests D) Cause an Nginx startup error