DEV Community

khimananda Oli
khimananda Oli

Posted on • Originally published at khimananda.com

Reverse Proxy for Node.js — Nginx and Apache2 Side by Side

In Part 1, we set up NVM and PM2. In Part 2, we started the Node.js application. Now let's put a reverse proxy in front of it.

Why a Reverse Proxy?

Your Node.js app should never be directly exposed on port 80 or 443. A reverse proxy handles:

  • SSL/TLS termination — Node doesn't deal with certificates
  • Security headers — added at the proxy layer
  • Static file serving — offload from Node
  • Request buffering — protects Node from slow clients
  • Centralized access logs

Both Nginx and Apache2 get the job done. Pick whichever is already in your stack.


Nginx

Install

sudo apt update && sudo apt install -y nginx
Enter fullscreen mode Exit fullscreen mode

Configuration

# /etc/nginx/sites-available/myapp.conf

upstream node_backend {
    server 127.0.0.1:3000;
    keepalive 64;
}

server {
    listen 80;
    server_name myapp.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name myapp.example.com;

    # SSL
    ssl_certificate     /etc/letsencrypt/live/myapp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem;

    # 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;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Proxy
    location / {
        proxy_pass http://node_backend;
        proxy_http_version 1.1;

        # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_cache_bypass $http_upgrade;

        # Pass client info
        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;
    }

    # Static files — serve directly, skip Node
    location /static/ {
        alias /opt/myapp/public/;
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Health check — suppress access logs
    location /health {
        proxy_pass http://node_backend;
        access_log off;
    }
}
Enter fullscreen mode Exit fullscreen mode

Enable and Test

sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Enter fullscreen mode Exit fullscreen mode

Apache2

Install and Enable Modules

sudo apt update && sudo apt install -y apache2
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers
sudo systemctl restart apache2
Enter fullscreen mode Exit fullscreen mode

Configuration

# /etc/apache2/sites-available/myapp.conf

<VirtualHost *:80>
    ServerName myapp.example.com

    RewriteEngine On
    RewriteCond %{HTTPS} off
    RewriteRule ^(.*)$ https://%{HTTP_HOST}$1 [R=301,L]
</VirtualHost>

<VirtualHost *:443>
    ServerName myapp.example.com

    SSLEngine On
    SSLCertificateFile    /etc/letsencrypt/live/myapp.example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/myapp.example.com/privkey.pem

    # Security headers
    Header always set X-Frame-Options "SAMEORIGIN"
    Header always set X-Content-Type-Options "nosniff"
    Header always set X-XSS-Protection "1; mode=block"
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"

    # Proxy to Node.js
    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/

    # WebSocket support
    RewriteEngine On
    RewriteCond %{HTTP:Upgrade} =websocket [NC]
    RewriteRule /(.*) ws://127.0.0.1:3000/$1 [P,L]

    # Pass real client IP
    RequestHeader set X-Real-IP "%{REMOTE_ADDR}s"
    RequestHeader set X-Forwarded-Proto "https"

    # Timeout
    ProxyTimeout 60

    # Static files
    Alias /static /opt/myapp/public
    <Directory /opt/myapp/public>
        Require all granted
        Options -Indexes
        Header set Cache-Control "public, max-age=2592000, immutable"
    </Directory>

    # Logging
    ErrorLog ${APACHE_LOG_DIR}/myapp-error.log
    CustomLog ${APACHE_LOG_DIR}/myapp-access.log combined
</VirtualHost>
Enter fullscreen mode Exit fullscreen mode

Enable and Test

sudo a2ensite myapp.conf
sudo a2dissite 000-default.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
Enter fullscreen mode Exit fullscreen mode

SSL with Let's Encrypt

# Nginx
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com

# Apache2
sudo apt install -y certbot python3-certbot-apache
sudo certbot --apache -d myapp.example.com
Enter fullscreen mode Exit fullscreen mode

Verify auto-renewal:

sudo certbot renew --dry-run
Enter fullscreen mode Exit fullscreen mode

If you already have certs (corporate CA, wildcard), just point the config to your cert and key paths.


Trust the Proxy in Your Node App

Your app needs to know it's behind a proxy to read the real client IP from X-Forwarded-For:

// Express
app.set('trust proxy', 1);
Enter fullscreen mode Exit fullscreen mode

Without this, req.ip will always return 127.0.0.1.


Nginx vs Apache2 — Comparison

Nginx Apache2
Architecture Event-driven, non-blocking Process/thread per connection
Memory Low footprint Higher under load
Static files Extremely fast Good, not as fast
WebSocket Native support Needs mod_proxy_wstunnel + rewrite
SSL Built-in Needs mod_ssl
Load balancing Built-in upstream Needs mod_proxy_balancer
.htaccess Not supported Supported
Config reload nginx -t && systemctl reload apache2ctl configtest && systemctl reload
Best for New setups, high concurrency Legacy stacks, PHP apps

Bottom line: Starting fresh → Nginx. Apache2 already in your stack → stick with it.


Verify the Full Stack

# PM2 running?
pm2 status

# Port listening?
ss -tlnp | grep 3000

# Reverse proxy responding?
curl -I https://myapp.example.com

# SSL valid?
echo | openssl s_client -connect myapp.example.com:443 2>/dev/null \
  | grep 'Verify return code'

# Proxy logs
sudo tail -f /var/log/nginx/access.log          # Nginx
sudo tail -f /var/log/apache2/myapp-access.log   # Apache2
Enter fullscreen mode Exit fullscreen mode

The Full Stack

Client (HTTPS:443)
    │
    ▼
┌──────────────────────┐
│  Nginx / Apache2     │  ← SSL, headers, static files
│  (port 80/443)       │
└──────────┬───────────┘
           │ proxy_pass http://127.0.0.1:3000
           ▼
┌──────────────────────┐
│  PM2 (cluster mode)  │  ← Process management, restarts
├──────────────────────┤
│  Node.js app         │  ← server.js / app.js
│  (port 3000)         │
└──────────────────────┘
           │
     NVM-managed Node binary
     ~/.nvm/versions/node/v20.18.0/bin/node
Enter fullscreen mode Exit fullscreen mode

Series Recap

  • Part 1 — NVM, PM2, startup scripts, log rotation
  • Part 2 — Running the app, cluster mode, memory limits, monitoring
  • Part 3 — Reverse proxy (Nginx + Apache2), SSL, security headers, verification

This stack is simple, debuggable, and production-proven. No containers, no orchestration overhead — just a Node app running reliably behind a proper proxy.


Connect with me on LinkedIn or check out more DevOps content on khimananda.com.

Top comments (0)