Node.js + Nginx Reverse Proxy Setup
Key takeaways
Running Node.js directly on port 80/443 in production is wrong. Nginx handles SSL termination, static files, compression, rate limiting, and routing so Node.js only handles application logic. This guide covers a complete production-ready Nginx + Node.js setup, including why each piece exists and what breaks when you skip it.
Why Put Nginx in Front of Node.js at All?
A common early mistake is binding an Express or Fastify app directly to port 80 or 443 and calling it done. It works in a demo, and then it quietly falls apart under real traffic. Node.js is single-threaded for JavaScript execution, its built-in HTTP server has no concept of TLS session caching or OS-level connection tuning, and every static asset request — every CSS file, every image, every favicon — competes with actual application logic for the same event loop. None of that is a flaw in Node.js; it simply was never designed to be an edge-facing web server.
Nginx is. It’s written in C, uses an event-driven architecture tuned over two decades for exactly this job, and offloads work that has nothing to do with your business logic: terminating TLS, compressing responses, serving files straight off disk, rejecting malformed requests before they ever reach your app, and multiplexing several backend processes behind one public IP. Putting Nginx in front of Node.js isn’t a cosmetic best practice — it changes what your Node process actually has to spend CPU cycles on, which directly affects how many requests per second a single instance can sustain.
The architecture below is the shape almost every production Node.js deployment converges on, whether it’s a single VPS or a fleet behind a load balancer:
flowchart LR
Client[Client Browser] -->|HTTPS :443| Nginx[Nginx]
Nginx -->|proxy_pass /api/*| Node[Node.js :3000]
Nginx -->|alias /static/*| Disk["(Static files on disk)"]
Nginx -->|Upgrade: websocket /ws/*| Node
Node -->|JSON / WS frames| Nginx
Nginx -->|HTTPS response| Client
Nginx sits on the public port and makes routing decisions per request path: API calls go to Node.js, static assets never touch Node.js at all, and WebSocket upgrade requests are proxied with the Upgrade header intact so the connection can be promoted from HTTP to a persistent socket. Everything below builds this setup piece by piece, and explains why each directive exists rather than just what it does.
Install Nginx
There’s rarely a good reason to compile Nginx from source for a standard reverse-proxy setup — the distro packages are recent enough, and using them means apt/dnf handles the systemd unit, log rotation config, and default directory layout for you. Compiling from source only makes sense if you need a specific third-party module (e.g., ngx_brotli) that isn’t in the packaged build.
# Ubuntu/Debian
sudo apt update && sudo apt install nginx
# CentOS/RHEL
sudo dnf install nginx
# macOS
brew install nginx
# Start and enable
sudo systemctl start nginx
sudo systemctl enable nginx
# Check status
sudo systemctl status nginx
systemctl enable is easy to forget and easy to regret — without it, Nginx won’t come back up after a server reboot, and you won’t notice until the next time the box restarts (often during an unrelated maintenance window, at the worst possible time). After installation, Nginx is already serving a default “Welcome to nginx” page on port 80; the rest of this guide replaces that default site with a config that proxies to your Node.js app.
Basic Reverse Proxy
At its simplest, a reverse proxy config is one server block with one location block that forwards everything to your Node.js port. The four proxy_set_header lines are not optional decoration — without them, your Node.js app sees every request as if it came from 127.0.0.1, which breaks IP-based rate limiting, geolocation, audit logs, and anything else that depends on knowing who the real client is.
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://localhost:3000;
# Required proxy headers
proxy_http_version 1.1;
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_http_version 1.1 matters more than it looks: Nginx defaults to HTTP/1.0 for upstream connections, which disables keepalive to the backend and forces a new TCP connection to Node.js for every single request. That’s a meaningful overhead under load, and it’s also a prerequisite for WebSocket proxying later, since upgrade requests require HTTP/1.1. X-Forwarded-For and X-Forwarded-Proto exist because Node.js is talking to Nginx over plain HTTP on localhost even when the client used HTTPS — without X-Forwarded-Proto, your app has no way to know the original request was encrypted, which matters for anything checking req.secure or building absolute URLs.
# Enable site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
# Test config
sudo nginx -t
# Reload (zero downtime)
sudo systemctl reload nginx
Always run nginx -t before reload. Nginx will happily accept a config reload command even if the file has a syntax error — reload just fails silently and keeps running the last known-good config, so if you skip the test step you can spend twenty minutes wondering why your change “isn’t taking effect” when in fact it was never loaded at all. reload (not restart) is the one that matters for production: it starts new worker processes with the updated config while letting old workers finish in-flight requests, so there’s no window where connections get dropped.
In Node.js, read the forwarded IP:
app.set('trust proxy', 1) // trust first proxy (Nginx)
app.get('/', (req, res) => {
console.log(req.ip) // real client IP, not 127.0.0.1
console.log(req.protocol) // 'https' even if Node sees http
})
trust proxy is the piece people forget, and skipping it has a subtle failure mode: without it, Express ignores the X-Forwarded-* headers entirely and req.ip silently returns Nginx’s address for every request. Rate limiters built on express-rate-limit will then throttle your entire user base as if it were one client, because every request appears to originate from 127.0.0.1. Setting the value to 1 tells Express to trust exactly one hop of proxy (Nginx itself) — use a higher number or a list of trusted IPs if you have additional proxies (a CDN, a load balancer) in front of Nginx.
HTTPS with Let’s Encrypt
Running production traffic over plain HTTP isn’t just bad practice for privacy — most browsers now actively downgrade the experience for HTTP sites (no HTTP/2, warnings on forms), and any API consumed by a mobile app or another service will often refuse plaintext connections outright. Let’s Encrypt removed the last excuse for skipping TLS: certificates are free, and Certbot automates both the initial issuance and the renewal, which used to be the part people got wrong.
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Get certificate (Certbot configures Nginx automatically)
sudo certbot --nginx -d api.example.com -d www.example.com
# Test auto-renewal
sudo certbot renew --dry-run
# Auto-renewal is set up via systemd timer or cron automatically
The --nginx plugin does two things beyond just fetching a certificate: it proves domain ownership by temporarily editing your Nginx config to serve a challenge file (or using the .well-known/acme-challenge/ path), and once the certificate is issued, it rewrites your server block to add the ssl_certificate directives and an HTTP→HTTPS redirect automatically. That sequence looks like this:
sequenceDiagram
participant You as Your server (Nginx)
participant CB as Certbot
participant LE as Let's Encrypt CA
You->>CB: certbot --nginx -d api.example.com
CB->>LE: Request certificate for api.example.com
LE-->>CB: Here is a challenge token
CB->>You: Write token to /.well-known/acme-challenge/
LE->>You: GET http://api.example.com/.well-known/acme-challenge/<token>
You-->>LE: 200 OK, token matches
LE-->>CB: Domain verified, issuing certificate
CB->>You: Write cert files, edit Nginx config, reload
This is why the HTTP-01 challenge (the default) requires port 80 to be open and reachable from the public internet at the moment you run certbot — Let’s Encrypt’s servers have to be able to fetch that token file over plain HTTP to prove you actually control the domain. If port 80 is blocked by a firewall, or server_name doesn’t match the domain you’re requesting a cert for, issuance fails with a validation error rather than a certificate.
Certificates from Let’s Encrypt are valid for 90 days, deliberately short so that the renewal automation gets exercised constantly rather than being a once-a-year process nobody remembers. certbot renew --dry-run doesn’t touch your real certificate — it simulates the renewal flow so you can confirm the systemd timer (or cron job, depending on your OS) that Certbot installs is actually going to work before you find out the hard way, three months from now, that it silently stopped.
After Certbot, your config becomes:
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri; # redirect HTTP -> HTTPS
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
# Modern SSL settings (added by Certbot)
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
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;
}
}
Notice the port-80 server block doesn’t disappear — it still has to exist, because it’s both the redirect target for anyone who types http:// and the location the renewal challenge hits every 60 days. A common mistake is deleting this block “for security” once HTTPS is working, which breaks automatic renewal the next time Certbot tries to prove domain ownership.
Complete Production Config
The minimal config above is enough to get traffic flowing, but it’s missing everything that makes a reverse proxy actually production-grade: modern TLS settings, security headers, compression, sane buffer sizes for real-world request/response sizes, and a first line of defense against obvious probing traffic. Each block below exists to solve a specific, concrete problem — not as boilerplate.
# /etc/nginx/sites-available/myapp
upstream node_app {
server localhost:3000;
# Load balancing (see section 6)
# server localhost:3001;
# server localhost:3002;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
http2 on; # nginx >= 1.25.1; on older versions use "listen 443 ssl http2;"
server_name api.example.com;
# SSL
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml+rss text/javascript image/svg+xml;
# Buffer settings
proxy_buffer_size 16k; # first part of the response: status line + headers
proxy_buffers 8 32k; # per-connection buffers for the body
proxy_busy_buffers_size 64k;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Static files (served by Nginx, not Node.js)
location /static/ {
alias /var/www/myapp/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# API routes -> Node.js
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_conn addr 20;
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Connection ""; # for keepalive
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;
}
# WebSocket
location /ws/ {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s; # keep WebSocket open
}
# Login endpoint: much stricter rate limit
location = /api/auth/login {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
}
# Health check (no logs)
location /health {
proxy_pass http://node_app;
access_log off;
}
# Block dotfiles (.env, .git, .svn, ...) but keep ACME challenges working
location ~ /\.(?!well-known/) {
return 404;
}
}
# /etc/nginx/nginx.conf -> http block additions
http {
# Rate limit zones
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_status 429; # default is 503, which looks like an outage to clients
limit_conn_status 429;
# Connection limit
limit_conn_zone $binary_remote_addr zone=addr:10m;
# Hide Nginx version
server_tokens off;
# Client body size
client_max_body_size 10m;
# Include site configs
include /etc/nginx/sites-enabled/*;
}
A few of these directives deserve more explanation than a comment gives them:
ssl_protocols TLSv1.2 TLSv1.3 deliberately excludes TLS 1.0 and 1.1. Both are formally deprecated (RFC 8996) and every major browser has removed support — keeping them enabled only widens your attack surface for no compatibility benefit. The cipher list similarly favors ECDHE (forward secrecy) over static RSA key exchange, so that a future compromise of your private key can’t be used to retroactively decrypt captured traffic.
The security headers each close a specific, real vector: Strict-Transport-Security tells browsers to never attempt plain HTTP to this domain again for the max-age window, which defeats SSL-stripping attacks on public Wi-Fi; X-Frame-Options DENY stops your login page from being embedded in an invisible iframe for clickjacking; X-Content-Type-Options nosniff stops browsers from executing a file as JavaScript just because it “looks like” a script, even if you served it with the wrong Content-Type.
ssl_ciphers only affects TLS 1.2; TLS 1.3 suites are configured separately and are all strong. The list above is the Mozilla “intermediate” set: ECDHE key exchange (forward secrecy) with AES-GCM or ChaCha20. Be careful copying cipher strings from old tutorials: names like ECDHE-RSA-AES256-GCM-SHA512 do not exist in OpenSSL and are silently ignored, so a list made mostly of them leaves you with far fewer ciphers than you think. The deprecated X-XSS-Protection header is intentionally left out; modern browsers ignore it, and the old filter it enabled introduced its own vulnerabilities. Use a Content-Security-Policy instead if you need XSS hardening at this layer.
proxy_buffer_size / proxy_buffers control how Nginx reads the response from Node.js. With buffering on (the default), Nginx reads the upstream response as fast as Node.js can produce it, releases the Node.js connection, and then feeds the client at the client’s speed. That is a real benefit: a slow mobile client downloading a 2MB JSON payload ties up a small Nginx buffer rather than a Node.js socket and its memory.
The two directives fail in different ways. proxy_buffer_size holds the first part of the response, including all headers. The default is one memory page (4k or 8k), and when a response carries large headers (many Set-Cookie lines, a big JWT in a cookie, long CSP headers), Nginx logs upstream sent too big header while reading response header from upstream and returns a 502 even though Node.js answered correctly. That error is one I have chased more than once: the app logs show a 200, the client sees a 502, and only error.log has the real reason. Raising proxy_buffer_size to 16k usually fixes it. proxy_buffers holds the body; when a response does not fit, Nginx spills it to a temporary file on disk (up to proxy_max_temp_file_size, 1GB by default), which is slower but not an error. If you see an upstream response is buffered to a temporary file warnings for normal API responses, raise the buffer count or size.
Do not simply set them very large. These buffers are allocated per active connection, so 8 32k means up to 256k per proxied request; at a few thousand concurrent connections, a config copied with 4 256k (1MB each) can consume gigabytes of RAM. Size them to your typical response, and for streaming endpoints (Server-Sent Events, long downloads) turn buffering off with proxy_buffering off; in that location, or send X-Accel-Buffering: no from Node.js.
limit_req_zone implements a leaky-bucket rate limiter keyed on client IP ($binary_remote_addr), with the 10m shared memory zone sized to track roughly 160,000 distinct IPs. rate=30r/m is the steady-state rate: Nginx tracks it at millisecond granularity, so it means “one request every 2 seconds”, not “30 requests at any point within a minute”. A page that fires eight API calls on load would be throttled immediately without a burst allowance.
burst=20 is that allowance: up to 20 requests above the rate are accepted into a queue. Without nodelay, queued requests are released at the configured rate, so the eighth call from a page load would wait about 14 seconds, which looks like a hung API. With nodelay, burst requests are forwarded immediately and only the burst slots refill at the configured rate; anything beyond the burst is rejected right away. For APIs, burst plus nodelay is almost always what you want. (Nginx 1.15.7+ also supports delay=N, which serves the first N burst requests immediately and paces the rest.)
The limits are applied per location, not at the server level, on purpose. A limit_req in the server block is inherited by every location that does not define its own, including /static/, so a single page with 40 assets would trip the API limit. The auth zone at 5 requests per minute is applied only to the login endpoint, where brute-force protection matters more than convenience, and limit_conn addr 20 caps concurrent connections per IP on the API. limit_req_status 429 changes the rejection code from the default 503 to 429 Too Many Requests, so clients (and your monitoring) can tell throttling apart from a real outage.
Two caveats. If Nginx sits behind a CDN or cloud load balancer, $binary_remote_addr is the load balancer’s IP and every client shares one bucket; configure the real_ip module (set_real_ip_from plus real_ip_header X-Forwarded-For) so the limiter sees real client addresses. And test the limit instead of trusting the config: fire 30 quick requests from one machine (for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/api/ping; done) and confirm you see 429s after the burst.
Blocking dotfiles like .env and .git matters because these are some of the most commonly automated-scanned paths on the internet — bots crawl for exposed .env files (which often contain database credentials or API keys) and .git directories (which can leak entire source trees) within minutes of a new IP going live. This location block returns a 404 before the request ever reaches Node.js, so even if your app has no explicit handling for these paths, they’re not silently served by a static file fallback. The negative lookahead (?!well-known/) keeps /.well-known/acme-challenge/ reachable; a blanket “deny all dotfiles” rule is a classic way to break Certbot renewal two months after the fact. (deny all is unnecessary next to return 404, because return runs in an earlier phase than access checks.)
WebSocket Proxying
HTTP and WebSocket look similar at the wire level — a WebSocket connection starts life as a normal HTTP request — but the moment a client and server agree to upgrade, the semantics change completely: what was a single request/response becomes a long-lived, bidirectional, framed connection that can stay open for hours. Nginx, by default, treats connections the way a typical reverse proxy does — buffering and closing them fairly aggressively — which is exactly wrong for WebSocket traffic unless you explicitly tell it otherwise.
# http block: send "Connection: upgrade" only when the client asked for an upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
# WebSocket upgrade headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Pass client info
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Don't close the connection
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_connect_timeout 7s;
}
The Upgrade and Connection: upgrade headers are the actual protocol-level signal that tells Nginx (and the upstream Node.js server) this isn’t a normal request — it’s an HTTP/1.1 101 Switching Protocols handshake. Without both of these set correctly, Nginx will either reject the upgrade or proxy it as a regular HTTP request that immediately closes, and clients using socket.io or the native WebSocket API will see connection errors or endless reconnect loops that are hard to diagnose from the browser console alone, since the failure happens at the proxy layer rather than in application code.
proxy_read_timeout 86400s (24 hours) is the other piece people miss. Nginx’s default read timeout is 60 seconds — fine for a typical API call, disastrous for a WebSocket connection that might sit idle between messages for longer than that. Without raising it, Nginx will silently kill “idle” WebSocket connections at the 60-second mark even though nothing is actually wrong, which shows up as clients randomly disconnecting and reconnecting under normal use. The map block is the pattern from the official Nginx documentation. Hard-coding Connection "upgrade" works for pure WebSocket paths, but Socket.IO starts with plain HTTP long-polling on the same /socket.io/ path before upgrading, and those ordinary requests should not carry an upgrade header. For Socket.IO, use location /socket.io/ with the same directives.
If you’re running behind a load balancer with least_conn or multiple Node.js instances (section 6), remember that WebSocket connections are stateful for their lifetime — a client that upgrades against instance A stays pinned to instance A for that connection, so any pub/sub or broadcast logic in your app needs a shared layer (Redis pub/sub is the common choice) if a message published from instance B needs to reach a client connected to instance A.
Load Balancing Multiple Node.js Instances
A single Node.js process uses one CPU core for its event loop — running more instances is the standard way to use the rest of the machine’s cores, and Nginx’s upstream block is what turns a pool of processes into something that looks like one backend to the outside world.
upstream node_cluster {
least_conn; # send to server with fewest connections (recommended)
# round_robin (default)
# ip_hash; # sticky sessions -> same client -> same server
server localhost:3000 weight=1;
server localhost:3001 weight=1;
server localhost:3002 weight=1;
# Health check
server localhost:3003 backup; # only used when others are down
keepalive 32; # keep connections warm
}
server {
listen 443 ssl;
# ...
location / {
proxy_pass http://node_cluster;
# ...
}
}
The three balancing strategies aren’t interchangeable — picking the wrong one causes real problems. Round-robin (the implicit default when nothing else is specified) distributes requests evenly by count, which is fine when every request is roughly the same cost, but can overload one instance if it happens to be handling several slow requests at once. least_conn fixes that by routing new requests to whichever backend currently has the fewest open connections, which is usually the better default for API traffic with uneven request durations. ip_hash is different in kind, not just degree: it deterministically maps a client IP to the same backend every time, which is necessary if your app keeps any session state in process memory (rather than in Redis or a database) — but it also means load can become uneven if a few high-traffic clients happen to hash to the same server, and it breaks entirely for clients behind a shared NAT or corporate proxy, since they’ll all appear to be one IP.
keepalive 32 on the upstream block matters for the same reason proxy_http_version 1.1 did earlier — without it, Nginx opens a fresh TCP connection to a backend for every request instead of reusing a small pool of warm connections, which adds latency and file-descriptor churn under load. The backup server is worth calling out too: it’s not part of the normal rotation at all, and only receives traffic when every non-backup server in the pool is down, which makes it a reasonable place for a lightweight degraded-mode instance rather than full capacity.
Run multiple Node.js processes:
# PM2 cluster mode
pm2 start app.js -i max # spawns one instance per CPU core
pm2 start app.js -i 4 # 4 workers sharing the same port
PM2’s cluster mode is the easiest way to get multiple instances running without manually managing separate processes and ports — it uses Node’s built-in cluster module under the hood, which forks workers that share the listening socket. One thing worth knowing: if you use PM2 cluster mode with all workers bound to the same port, you don’t actually need the upstream block with multiple server lines above — point Nginx at the single shared port instead, and let PM2 handle distribution internally. The explicit upstream with distinct ports (3000, 3001, 3002…) shown here is the pattern to use when you’re running instances as genuinely separate processes (e.g., via systemd or Docker) rather than through PM2’s cluster mode, since each one then needs its own port to bind to.
In Docker Compose, the same idea uses service names instead of ports on localhost: if Nginx and the API share a Compose network, the upstream is server api:3000;, and Docker’s DNS resolves api to the container. Two things to know there. Nginx resolves upstream hostnames once at startup, so if the api container is recreated with a new IP, Nginx keeps sending traffic to the old address until it is reloaded. And Nginx refuses to start if api is not resolvable yet, so start Nginx after the API (depends_on), or use resolver 127.0.0.11 with a variable in proxy_pass to resolve at request time.
For blue/green deployments on a single host, run the new version on a second port (or a second Compose service), wait for its /health endpoint to pass, switch the upstream to point at it, and run nginx -s reload. Reload is graceful, so in-flight requests finish on the old workers while new requests go to the new version; stop the old instance only after its connections drain.
Caching API Responses
Not every API response needs to hit Node.js on every request. Anything public, non-personalized, and safe to serve slightly stale — a product catalog, a public leaderboard, a list of blog posts — is a good candidate for Nginx’s own response cache, which serves subsequent identical requests straight from memory or disk without touching your application at all.
# Define cache zone in http block
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=api_cache:10m
max_size=1g inactive=60m use_temp_path=off;
server {
# ...
location /api/public/ {
proxy_pass http://node_app;
# Cache responses
proxy_cache api_cache;
proxy_cache_valid 200 5m; # cache 200 responses for 5 minutes
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Add cache status header for debugging
add_header X-Cache-Status $upstream_cache_status;
# Don't cache if Authorization header present
proxy_cache_bypass $http_authorization;
proxy_no_cache $http_authorization;
}
}
The most important line to understand here is the last pair: proxy_cache_bypass and proxy_no_cache, both keyed on $http_authorization. Without them, Nginx by default caches responses based on the request path alone — it has no idea that two requests to the same URL with different Authorization headers should get different, private responses. Caching an authenticated response and then serving it to a different user is a real data leak, not a theoretical one, and it’s a mistake that’s easy to make by copying a caching snippet without understanding what it keys on. If your API has any per-user or per-session data on a path you’re caching, either exclude that path from the cache entirely or key the cache on something that captures the identity (rare and usually not worth the complexity) — the safe default is what’s shown above: never cache when credentials are present.
proxy_cache_use_stale is the resilience half of this block: if Node.js is slow, timing out, or returning 5xx errors, Nginx will serve the last good cached response instead of propagating the failure to the client. Combined with proxy_cache_background_update on, Nginx serves the stale copy immediately while refreshing it in the background, so users experience a brief window of slightly outdated data during an incident rather than an outright error page. proxy_cache_lock on prevents the “cache stampede” problem — when a cached entry expires and many concurrent requests arrive at once, only the first one actually reaches Node.js to regenerate the cache; the rest wait briefly for that response rather than all hammering the backend simultaneously. The X-Cache-Status header (HIT, MISS, BYPASS, EXPIRED, etc.) is invaluable for debugging — leave it in during development so you can see with curl -I exactly what the cache is doing, and consider stripping it in a fully locked-down production environment if you don’t want to expose caching internals to clients.
Nginx Commands Reference
# Test config syntax
sudo nginx -t
# Reload config (zero downtime)
sudo systemctl reload nginx
# Restart (brief downtime)
sudo systemctl restart nginx
# View error logs
sudo tail -f /var/log/nginx/error.log
# View access logs
sudo tail -f /var/log/nginx/access.log
# View logs for specific site
sudo tail -f /var/log/nginx/myapp.access.log
# Check which ports Nginx is using
sudo ss -tlnp | grep nginx
error.log is where you should look first for almost every proxy problem — connection refused to the upstream, SSL handshake failures, config parse errors after a reload — while access.log tells you what actually happened per request (status code, response time, upstream address) and is the better source for diagnosing slow endpoints or unexpected traffic patterns. ss -tlnp | grep nginx is a quick sanity check worth running whenever something “isn’t working” after a config change: it confirms Nginx is actually bound to the ports you think it is, which rules out a whole class of problems (like a leftover Apache install still holding port 80) in a few seconds.
Who owns what: Nginx or Node.js
| Concern | Nginx handles | Node.js handles |
|---|---|---|
| SSL/TLS | Termination | Nothing |
| Static files | Direct serve | Nothing |
| Compression | Gzip | Nothing |
| Rate limiting | Per-IP limits | Per-user limits |
| Load balancing | Upstream pool | Nothing |
| Security headers | HSTS, X-Frame | App-level |
| Application logic | Nothing | Everything |
The table’s last row is the real point of this entire setup: the split of responsibility isn’t arbitrary. Nginx owns everything that’s about transport and delivery — encryption, compression, connection handling, rejecting bad requests early — and Node.js owns everything that’s about what the response actually contains. Keeping that boundary clean is what lets you scale each side independently: add more Node.js instances when application logic is the bottleneck, tune Nginx buffers and worker counts when connection handling is the bottleneck, and diagnose which one you’re actually dealing with by checking access.log response times against your app’s own logs.
Troubleshooting
A few failures come up often enough with this setup that they’re worth documenting directly rather than leaving you to rediscover them:
502 Bad Gateway. This means Nginx successfully accepted the client’s request but couldn’t get a usable response from the upstream. The usual causes, in order of likelihood: Node.js isn’t actually running or crashed after a deploy, Node.js is listening on a different port than what proxy_pass points to, or (less obviously) SELinux is blocking Nginx from making outbound connections even to localhost — sudo setsebool -P httpd_can_network_connect 1 on RHEL/CentOS-family systems fixes that last one. Check error.log first; it will name the specific upstream and the specific reason (connection refused, timed out, etc.).
413 Request Entity Too Large. Nginx enforces client_max_body_size (default 1MB) before the request body ever reaches Node.js. If your app accepts file uploads or large JSON payloads, this needs to be raised explicitly in the server or location block handling that endpoint — raising it in Express with express.json({ limit: '10mb' }) alone does nothing if Nginx rejects the request first.
Config changes not taking effect after reload. Nearly always means the reload silently failed. Run sudo nginx -t to see the actual syntax error, since systemctl reload nginx won’t show you one on its own in every setup.
Redirects or generated links use http:// instead of https://. Node.js only sees plain HTTP from Nginx. Make sure every proxied location sets X-Forwarded-Proto $scheme and that Express has trust proxy enabled; otherwise req.protocol is http, and redirects, OAuth callback URLs, and Secure cookies are built for the wrong scheme. If a CDN terminates TLS in front of Nginx, $scheme is http there too, so pass through the CDN’s own X-Forwarded-Proto instead.
502 with “upstream sent too big header” in error.log. The response headers from Node.js exceed proxy_buffer_size, usually because of large cookies. Raise it as described in section 4.
WebSocket connects then immediately drops. Almost always a missing or incomplete Upgrade/Connection header pair on the specific location block handling the WebSocket path — it’s common to add these correctly to one location and forget them on another when a site has multiple WS endpoints.
Frequently Asked Questions (FAQ)
Q. Why do my rate-limited clients get 503 instead of 429?
A. Nginx rejects requests over a limit_req or limit_conn limit with 503 by default. Set limit_req_status 429; and limit_conn_status 429; in the http or server block so clients can back off correctly and monitoring does not report throttling as downtime.
Q. Do I need this if I’m deploying behind a managed load balancer (AWS ALB, Cloudflare, etc.)?
A. Often you still want Nginx on the box itself, even with a managed edge in front of you. A cloud load balancer typically handles TLS termination and basic routing, but Nginx is still useful for local static file serving, per-instance rate limiting, and buffering — and running it consistently means your local dev setup and production topology don’t diverge. If your managed layer already does everything Nginx would do here, it’s reasonable to skip a local Nginx and have it proxy straight from the load balancer to Node.js.
Related Articles
- Nginx Guide — core concepts, server blocks, and locations
- Socket.IO Guide — the WebSocket side of section 5
- Docker Compose Guide — running Nginx and Node.js as services
- GitHub Actions CI/CD — automating deploys and reloads