Node.js + Nginx Reverse Proxy Setup | Production Configuration Guide

Node.js + Nginx Reverse Proxy Setup | Production Configuration Guide

이 글의 핵심

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.

Architecture

Internet → Nginx (port 80/443)
             ├── /api/*     → Node.js (port 3000)
             ├── /static/*  → Serve files directly (no Node.js)
             └── /ws/*      → Node.js WebSocket (port 3000)

Nginx handles:

  • SSL termination (HTTPS → HTTP to Node.js)
  • Gzip compression
  • Static file serving
  • Rate limiting
  • Security headers

1. Install Nginx

# 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

2. Basic Reverse Proxy

# /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;
    }
}
# 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

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
})

3. HTTPS with Let’s Encrypt

# 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

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;
    }
}

4. Complete Production Config

# /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;
    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-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384;
    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 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;

    # 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          128k;
    proxy_buffers              4 256k;
    proxy_busy_buffers_size    256k;

    # 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/ {
        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
    }

    # Health check (no logs)
    location /health {
        proxy_pass http://node_app;
        access_log off;
    }

    # Block common attack patterns
    location ~* \.(env|git|svn) {
        deny all;
        return 404;
    }

    # Rate limiting (define zone in http block)
    limit_req zone=api burst=20 nodelay;
}
# /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;

    # 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/*;
}

5. WebSocket Proxying

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 "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;
}

6. Load Balancing Multiple Node.js Instances

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;
        # ...
    }
}

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 instances on ports 3000-3003

7. Caching API Responses

# 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;
    }
}

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

Key Takeaways

ConcernNginx handlesNode.js handles
SSL/TLSTerminationNothing
Static filesDirect serveNothing
CompressionGzipNothing
Rate limitingPer-IP limitsPer-user limits
Load balancingUpstream poolNothing
Security headersHSTS, X-FrameApp-level
Application logicNothingEverything
  • Trust proxy: set app.set('trust proxy', 1) in Express to get real client IP
  • HTTP/2: use listen 443 ssl http2 — multiplexed connections improve performance
  • WebSocket: requires Upgrade and Connection headers in location block
  • Let’s Encrypt: certbot --nginx configures SSL automatically, renews every 90 days