How to Configure Nginx as a Reverse Proxy for a Node.js App
If your Node.js app is still answering requests directly on port 3000, you are missing TLS termination, static file caching, request buffering, rate limiting and a clean way to run several apps on one machine. An Nginx reverse proxy for Node.js solves all of that in about twenty lines of configuration. This tutorial is not a copy of the usual three-line proxy_pass snippet. You get a complete production server block, an explanation of every directive, correct WebSocket upgrade handling, SSL termination, and the three errors that break 90% of setups (502 Bad Gateway, wrong Host headers, port conflicts) with the exact commands to diagnose them. Why put Nginx in front of Node.js at all? Node.js is single-threaded per process and excellent at application logic, but it is not the best tool for the jobs a mature web server has done for twenty years. Nginx acts as the traffic cop in front of your process: SSL/TLS termination: certificates, HTTP/2 and HSTS live in Nginx, not in your JavaScript code. Static assets: images, CSS and bundles are served from disk by Nginx, without waking up the event loop. Port 80/443 binding: your Node process runs unprivileged on a high port on localhost. Slow client protection: Nginx buffers slow requests and responses so a bad mobile connection does not tie up a Node handler. Multiple apps, one IP: virtual hosts route api.example.com and app.example.com to different ports. Zero-downtime deploys and load balancing: an upstream block with several Node instances or a blue/green swap. Performance note: the extra hop costs a fraction of a millisecond on loopback. In exchange you get keepalive pooling, gzip/brotli, caching and connection buffering, which is almost always a net win on real traffic. What you need before you start A Linux server (Ubuntu 24.04/26.04 LTS, Debian 12/13, Rocky/Alma 9 or similar) with root or sudo access. Node.js 22 LTS or 24 LTS installed and an app that listens on a local port. Nginx 1.25 or newer (needed for the modern http2 on; directive). A domain name with an A/AAAA record pointing to the server, if you want HTTPS. Ports 80 and 443 open in your firewall or cloud security group. Step 1: make your Node.js app listen on localhost only The single most important line in your app is the bind address. Listening on 0.0.0.0 exposes port 3000 to the whole internet and lets people bypass Nginx entirely. // server.js const express = require(‘express’); const app = express(); // Trust the proxy so req.ip and req.protocol come from X-Forwarded-* headers app.set(‘trust proxy’, ‘loopback’); app.get(‘/’, (req, res) => { res.json({ ip: req.ip, proto: req.protocol, host: req.hostname }); }); const PORT = process.env.PORT || 3000; app.listen(PORT, ‘127.0.0.1’, () => { console.log(`Listening on http://127.0.0.1:${PORT}`); }); Two things matter here: app.listen(PORT, ‘127.0.0.1’) binds to loopback, so only Nginx can reach it. app.set(‘trust proxy’, ‘loopback’) tells Express to read X-Forwarded-For and X-Forwarded-Proto. Without it, every visitor looks like 127.0.0.1 and req.secure is always false, which breaks secure cookies and redirect logic. Quick check: curl -i http://127.0.0.1:3000/ If that does not return 200 from the server itself, Nginx will never work. Fix the app first. Step 1b: keep the process alive with systemd A reverse proxy is useless if the backend dies on logout. Create /etc/systemd/system/nodeapp.service: [Unit] Description=Node.js app behind Nginx After=network.target [Service] Type=simple User=nodeapp WorkingDirectory=/var/www/app Environment=NODE_ENV=production Environment=PORT=3000 ExecStart=/usr/bin/node server.js Restart=always RestartSec=3 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target sudo systemctl daemon-reload sudo systemctl enable –now nodeapp sudo systemctl status nodeapp PM2 works too, but systemd needs no extra dependency and integrates with journalctl -u nodeapp -f for logs. Step 2: install Nginx Debian and Ubuntu sudo apt update sudo apt install nginx -y sudo systemctl enable –now nginx nginx -v Rocky Linux, AlmaLinux, RHEL sudo dnf install nginx -y sudo systemctl enable –now nginx sudo firewall-cmd –permanent –add-service=http –add-service=https sudo firewall-cmd –reload Visit http://your-server-ip/. The default Nginx welcome page confirms the install. Config files live in: Distribution Where to put your server block Enable it Debian / Ubuntu /etc/nginx/sites-available/app.conf symlink into sites-enabled/ RHEL family /etc/nginx/conf.d/app.conf loaded automatically Step 3: the WebSocket upgrade map (do this first) Create /etc/nginx/conf.d/upgrade.conf so the map is defined once in the http context and reusable by every site: map $http_upgrade $connection_upgrade { default upgrade; ” “”; } Important detail most tutorials get wrong: the common snippet maps the empty value to close. That works, but it also closes the connection to your Node backend on every normal HTTP request, which disables upstream keepalive. Mapping to an empty string clears the Connection header instead, so plain requests reuse pooled connections and WebSocket requests still get Connection: upgrade. Step 4: the production-ready Nginx reverse proxy config This is the file you can copy. Replace app.example.com, the port, and the static path. upstream node_app { server 127.0.0.1:3000 max_fails=3 fail_timeout=10s; keepalive 64; } # HTTP: redirect everything to HTTPS server { listen 80; listen [::]:80; server_name app.example.com; location /.well-known/acme-challenge/ { root /var/www/html; } location / { return 301 https://$host$request_uri; } } # HTTPS: SSL termination + reverse proxy server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name app.example.com; ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets off; add_header Strict-Transport-Security “max-age=31536000; includeSubDomains” always; add_header X-Content-Type-Options nosniff always; server_tokens off; client_max_body_size 25m; client_body_timeout 30s; access_log /var/log/nginx/app.access.log; error_log /var/log/nginx/app.error.log warn; gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_proxied any; gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml; # Static files straight from disk, never through Node location /static/ { alias /var/www/app/public/; access_log off; expires 30d; add_header Cache-Control “public, immutable”; try_files $uri =404; } # Long-lived WebSocket endpoint location /socket.io/ { proxy_pass http://node_app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $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_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # Everything else goes to Node.js location / { proxy_pass http://node_app; 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; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Port $server_port; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_connect_timeout 5s; proxy_send_timeout 60s; proxy_read_timeout 60s;
How to Configure Nginx as a Reverse Proxy for a Node.js App Read More »
