Tools: Reverse Proxy for Node.js — Nginx and Apache2 Side by Side
Why a Reverse Proxy?
Install
Configuration
Enable and Test
Apache2
Install and Enable Modules
Configuration
Enable and Test
SSL with Let's Encrypt
Trust the Proxy in Your Node App
Nginx vs Apache2 — Comparison
Verify the Full Stack
The Full Stack
Series Recap 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. Your Node.js app should never be directly exposed on port 80 or 443. A reverse proxy handles: Both Nginx and Apache2 get the job done. Pick whichever is already in your stack. If you already have certs (corporate CA, wildcard), just point the config to your cert and key paths. Your app needs to know it's behind a proxy to read the real client IP from X-Forwarded-For: Without this, req.ip will always return 127.0.0.1. Bottom line: Starting fresh → Nginx. Apache2 already in your stack → stick with it. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
$ -weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y nginx
# /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 '-weight: 500;">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; }
}
# /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 '-weight: 500;">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; }
}
# /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 '-weight: 500;">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; }
}
-weight: 600;">sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
-weight: 600;">sudo nginx -t
-weight: 600;">sudo -weight: 500;">systemctl reload nginx
-weight: 600;">sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
-weight: 600;">sudo nginx -t
-weight: 600;">sudo -weight: 500;">systemctl reload nginx
-weight: 600;">sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
-weight: 600;">sudo nginx -t
-weight: 600;">sudo -weight: 500;">systemctl reload nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y apache2
-weight: 600;">sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart apache2
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y apache2
-weight: 600;">sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart apache2
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">update && -weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y apache2
-weight: 600;">sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers
-weight: 600;">sudo -weight: 500;">systemctl -weight: 500;">restart apache2
# /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>
# /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>
# /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>
-weight: 600;">sudo a2ensite myapp.conf
-weight: 600;">sudo a2dissite 000-default.conf
-weight: 600;">sudo apache2ctl configtest
-weight: 600;">sudo -weight: 500;">systemctl reload apache2
-weight: 600;">sudo a2ensite myapp.conf
-weight: 600;">sudo a2dissite 000-default.conf
-weight: 600;">sudo apache2ctl configtest
-weight: 600;">sudo -weight: 500;">systemctl reload apache2
-weight: 600;">sudo a2ensite myapp.conf
-weight: 600;">sudo a2dissite 000-default.conf
-weight: 600;">sudo apache2ctl configtest
-weight: 600;">sudo -weight: 500;">systemctl reload apache2
# Nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-nginx
-weight: 600;">sudo certbot --nginx -d myapp.example.com # Apache2
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-apache
-weight: 600;">sudo certbot --apache -d myapp.example.com
# Nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-nginx
-weight: 600;">sudo certbot --nginx -d myapp.example.com # Apache2
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-apache
-weight: 600;">sudo certbot --apache -d myapp.example.com
# Nginx
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-nginx
-weight: 600;">sudo certbot --nginx -d myapp.example.com # Apache2
-weight: 600;">sudo -weight: 500;">apt -weight: 500;">install -y certbot python3-certbot-apache
-weight: 600;">sudo certbot --apache -d myapp.example.com
-weight: 600;">sudo certbot renew --dry-run
-weight: 600;">sudo certbot renew --dry-run
-weight: 600;">sudo certbot renew --dry-run
// Express
app.set('trust proxy', 1);
// Express
app.set('trust proxy', 1);
// Express
app.set('trust proxy', 1);
# PM2 running?
pm2 -weight: 500;">status # Port listening?
ss -tlnp | grep 3000 # Reverse proxy responding?
-weight: 500;">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
-weight: 600;">sudo tail -f /var/log/nginx/access.log # Nginx
-weight: 600;">sudo tail -f /var/log/apache2/myapp-access.log # Apache2
# PM2 running?
pm2 -weight: 500;">status # Port listening?
ss -tlnp | grep 3000 # Reverse proxy responding?
-weight: 500;">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
-weight: 600;">sudo tail -f /var/log/nginx/access.log # Nginx
-weight: 600;">sudo tail -f /var/log/apache2/myapp-access.log # Apache2
# PM2 running?
pm2 -weight: 500;">status # Port listening?
ss -tlnp | grep 3000 # Reverse proxy responding?
-weight: 500;">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
-weight: 600;">sudo tail -f /var/log/nginx/access.log # Nginx
-weight: 600;">sudo tail -f /var/log/apache2/myapp-access.log # Apache2
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
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
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 - 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 - 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