Reverse Proxy with Nginx
Putting Nginx in front of Express is the standard way to run Node in production on a server you control. Nginx handles the things Node is bad at or shouldn’t do at all: terminating TLS, compressing responses, serving static assets quickly, and spreading traffic across multiple Express processes. Your app keeps speaking plain HTTP on a private port while Nginx faces the internet. This page shows the proxy configuration, TLS and gzip setup, the headers Nginx forwards, and how to make Express trust them.
Why front Express with Nginx
A bare Node process can listen on port 443 and parse TLS, but you don’t want it to. Nginx is a hardened, battle-tested C server that does connection handling, slow-client buffering, and certificate management far more efficiently than the Node event loop. The pattern is simple: Nginx listens on 80/443, Express listens on 127.0.0.1:3000, and Nginx proxies requests to it.
| Concern | Who handles it | Why |
|---|---|---|
| TLS / HTTPS | Nginx | Offloads crypto, central cert management |
| Static files | Nginx | Serves from disk without touching Node |
| Compression (gzip/brotli) | Nginx | Frees the event loop |
| Load balancing | Nginx upstream | Distributes across Node processes |
| App logic, routing, sessions | Express | What Node is actually for |
Basic reverse proxy configuration
Express binds to localhost only — it should never be reachable directly from outside. Nginx forwards every request to it and passes along the original client details.
import express from "express";
const app = express();
app.set("trust proxy", 1); // trust the first hop (Nginx)
app.get("/", (req, res) => {
res.json({ ip: req.ip, protocol: req.protocol, host: req.hostname });
});
app.listen(3000, "127.0.0.1", () => console.log("Express on 127.0.0.1:3000"));
The matching Nginx server block proxies / to that upstream:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1: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;
proxy_set_header X-Forwarded-Host $host;
# Required for WebSocket / SSE upgrades
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Forwarding headers and trust proxy
When a request passes through Nginx, the TCP connection Express sees comes from 127.0.0.1, and the protocol is plain HTTP. Without help, req.ip would always be the proxy and req.protocol would always be http. The X-Forwarded-* headers carry the real values, and app.set("trust proxy", 1) tells Express to read them.
The number is how many trusted proxy hops sit in front of you. With a single Nginx instance, use 1. The setting affects three things: req.ip (uses the leftmost untrusted X-Forwarded-For entry), req.protocol (reads X-Forwarded-Proto), and req.secure — which express-session and secure cookies depend on.
| Header | Nginx variable | Express reads it as |
|---|---|---|
X-Forwarded-For | $proxy_add_x_forwarded_for | req.ip / req.ips |
X-Forwarded-Proto | $scheme | req.protocol, req.secure |
Host | $host | req.hostname |
Output: with trust proxy enabled, a request over HTTPS through Nginx returns the real client values.
GET https://api.example.com/
{ "ip": "203.0.113.42", "protocol": "https", "host": "api.example.com" }
Warning: Never set
app.set("trust proxy", true)(trust everyone) on an internet-facing app. Any client could spoofX-Forwarded-Forand bypass IP-based rate limiting or logging. Trust the exact number of hops you actually run, or a specific subnet likeapp.set("trust proxy", "loopback").
TLS termination
Terminate HTTPS at Nginx and forward plain HTTP to Express on the loopback interface. With certificates from Let’s Encrypt (via Certbot) the server block becomes:
server {
listen 443 ssl http2;
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;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
}
}
# Redirect all plain HTTP to HTTPS
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
Gzip and static files
Let Nginx compress responses and serve static assets straight from disk so they never reach Node. Add gzip to the http {} block (or inside the server), and a location that points at your build output.
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1024;
location /static/ {
alias /var/www/app/public/;
expires 30d;
access_log off;
}
Load balancing across multiple instances
Run several Express processes (for example via PM2 cluster mode) on different ports and define an upstream so Nginx distributes requests across them.
upstream express_app {
least_conn;
server 127.0.0.1:3000;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 443 ssl http2;
server_name api.example.com;
# ...ssl directives...
location / {
proxy_pass http://express_app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
}
Reload Nginx after editing config — it validates first, so a typo won’t take the site down:
sudo nginx -t && sudo systemctl reload nginx
Output:
nginx: configuration file /etc/nginx/nginx.conf test is successful
Best Practices
- Bind Express to
127.0.0.1(or a private interface), never0.0.0.0, so only Nginx can reach it. - Set
trust proxyto the exact number of hops, and verifyreq.secureistruebefore relying on secure cookies. - Always forward
X-Forwarded-Proto,X-Forwarded-For, andHost; missing headers break redirects, logging, and sessions. - Terminate TLS at Nginx with TLSv1.2+, redirect all port 80 traffic to 443, and automate certificate renewal with Certbot.
- Serve static files and run gzip in Nginx so the Node event loop only handles dynamic work.
- Run
nginx -tbefore everyreload, and use anupstreamblock withleast_connto balance across multiple Express instances. - Set
proxy_http_version 1.1and theUpgrade/Connectionheaders when your app uses WebSockets or server-sent events.