Why a Reverse Proxy?
Nginx sits in front of your Express app, handling concerns that Node.js shouldn’t handle directly:
Internet ───→ Nginx (port 80/443) ───→ Express (port 3000)
│
├── SSL termination
├── Static file serving
├── Load balancing
├── Rate limiting
├── Compression
├── Security headers
└── Request buffering
| Task | Nginx | Express |
|---|---|---|
| SSL/TLS | ✅ | ❌ (use Nginx) |
| Static files | ✅ (fast) | ✅ (but slower) |
| Compression | ✅ | ✅ (compression) |
| Rate limiting | ✅ | ✅ (express-rate-limit) |
| Load balancing | ✅ | ❌ |
| Serving the app | ❌ | ✅ |
Basic Nginx Reverse Proxy
Installation
# Ubuntu/Debian
sudo apt update
sudo apt install nginx
# CentOS/RHEL
sudo yum install nginx
Minimal Config
# /etc/nginx/sites-available/myapp
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 Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
}
# Enable site
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
Important Nginx Proxy Headers
location / {
proxy_pass http://127.0.0.1:3000;
# Required for WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Required for correct client 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 X-Forwarded-Proto $scheme;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffer sizes
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
Express Must Trust Proxy
// Essential when behind Nginx
app.set('trust proxy', 1);
// Now req.ip returns the real client IP
// req.protocol returns 'https' if Nginx sets X-Forwarded-Proto
SSL Termination with Let’s Encrypt
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Get SSL certificate
sudo certbot --nginx -d api.example.com
# Auto-renew (certbot adds a systemd timer)
sudo certbot renew --dry-run
Nginx SSL Config
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;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_pass http://127.0.0.1:3000;
# ... proxy headers
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
Load Balancing Multiple Express Instances
upstream express_cluster {
least_connections; # Send to least busy instance
# ip_hash; # Stick sessions to same instance
# random; # Random distribution
server 127.0.0.1:3001 weight=3;
server 127.0.0.1:3002 weight=2;
server 127.0.0.1:3003 weight=1;
server 127.0.0.1:3004 backup; # Only used if others are down
}
server {
listen 443 ssl http2;
server_name api.example.com;
location / {
proxy_pass http://express_cluster;
# ... proxy headers
}
}
Starting Express Instances with PM2
# Start 4 instances (one per CPU core)
pm2 start index.js -i 4 --name "myapp"
# Nginx automatically load balances across them
Serving Static Files via Nginx
Express is slow at serving static files. Let Nginx handle them:
server {
listen 443 ssl http2;
server_name example.com;
# Serve static files directly (Nginx is much faster)
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
location /uploads/ {
alias /var/www/myapp/uploads/;
expires 7d;
add_header Cache-Control "public";
}
# Everything else goes to Express
location / {
proxy_pass http://127.0.0.1:3000;
# ... proxy headers
}
}
Rate Limiting at Nginx Level
# Define a rate limit zone
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s;
server {
listen 443 ssl http2;
server_name api.example.com;
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /api/login {
limit_req zone=login_limit:10m rate=5r/m;
proxy_pass http://127.0.0.1:3000;
}
}
Security Headers at Nginx Level
server {
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';" always;
# HSTS (forces HTTPS)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
}
Full Production Nginx Config
# /etc/nginx/sites-available/myapp
upstream express_app {
least_connections;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
server 127.0.0.1:3004;
}
server {
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 HIGH:!aNULL:!MD5;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Logging
access_log /var/log/nginx/myapp-access.log;
error_log /var/log/nginx/myapp-error.log;
# Gzip
gzip on;
gzip_types application/json application/javascript text/css text/plain;
gzip_min_length 1000;
# Static files
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# API
location /api/ {
limit_req zone=api_limit:10m rate=100r/s burst=20;
proxy_pass http://express_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_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 90s;
}
# WebSocket (Socket.IO)
location /socket.io/ {
proxy_pass http://express_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
}
# Default - proxy to Express
location / {
proxy_pass http://express_app;
# ... proxy headers
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name api.example.com;
return 301 https://$server_name$request_uri;
}
Troubleshooting
# Test config
sudo nginx -t
# Check status
sudo systemctl status nginx
# View logs
sudo tail -f /var/log/nginx/error.log
sudo tail -f /var/log/nginx/access.log
# Reload after config change
sudo systemctl reload nginx
# Check what's listening on port 80
sudo ss -tlnp | grep :80
Key Takeaways
- Nginx is a reverse proxy that sits between clients and Express
- Handle SSL termination, static files, and gzip at the Nginx level
- Use
upstreamblocks for load balancing across multiple Express instances - Set
app.set('trust proxy', 1)in Express for correct client IPs - Use Let’s Encrypt (Certbot) for free SSL certificates
- Add rate limiting and security headers at Nginx for performance
- Configure WebSocket support with appropriate proxy headers and timeouts
- Always redirect HTTP to HTTPS