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
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;
}
}
Enable and Test
sudo ln -s /etc/nginx/sites-available/myapp.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
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
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>
Enable and Test
sudo a2ensite myapp.conf
sudo a2dissite 000-default.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
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
Verify auto-renewal:
sudo certbot renew --dry-run
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);
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
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
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)