Reverse-Proxy Node.js Behind nginx on Ubuntu
Put nginx in front of a Node.js app bound to 127.0.0.1, terminate TLS at the proxy, and keep the Node runtime off the public internet entirely.
Netbay Infrastructure Team
Netbay Engineering
On this page
Node should not be the process that owns port 443. It is a language runtime, not a TLS terminator, and it is a poor place to load cipher lists, OCSP stapling, and rate limits. nginx already does that work. This post assumes nginx is installed and running on Ubuntu. It does not re-teach server blocks from scratch. The only job here is to put a Node HTTP server on 127.0.0.1 and let nginx be the only socket the internet can see.
Bind the app to loopback, proxy_pass to that address, and forward the headers Node needs to reconstruct the original request. That is the entire production front door on a single VPS.
Bind Node on loopback, not on 0.0.0.0
If server.listen uses 0.0.0.0:3000, anyone who can reach port 3000 bypasses nginx. UFW or nftables can hide that mistake until someone scans the host. Listen on 127.0.0.1 and you do not have to remember a firewall rule for the app port. nginx on the same machine can still reach it. The public NIC never sees the Node process.
Confirm with ss -lntp that node is bound to 127.0.0.1:3000 and that nginx owns 80 and 443. If you see node on *:3000, fix the listen call before you write a proxy_pass.
const http = require('http');
const port = Number(process.env.PORT) || 3000;
http.createServer(function (req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('via nginx host=' + String(req.headers.host) + '\n');
}).listen(port, '127.0.0.1');The site file that actually talks to Node
A site enabled in sites-enabled should proxy to the loopback port and set the forwarded headers. proxy_http_version 1.1 plus Connection "" keeps keepalive to Node so every request is not a fresh TCP handshake. Host must be passed through or req.headers.host becomes 127.0.0.1:3000 and signed cookies and absolute redirects break.
server {
listen 80;
listen [::]:80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
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;
location / {
proxy_pass http://127.0.0.1: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;
proxy_set_header Connection "";
proxy_read_timeout 60s;
}
}nginx -t then systemctl reload nginx. Reload, not restart, so existing TLS connections are not dropped while you pick up the new site file. If nginx -t fails, do not reload; a bad site file is how you take the whole box off the air for a missing semicolon.
X-Forwarded-For is a list. In Node, do not trust req.socket.remoteAddress for client IP once you are behind a proxy; that address is always 127.0.0.1. Read the last untrusted hop only if nginx is the only proxy you control. Express users set trust proxy to 1 so req.ip and secure cookies follow X-Forwarded-Proto. Rolling your own parser is how you let a client spoof the header. Because nginx overwrites X-Forwarded-For from $proxy_add_x_forwarded_for when it is the edge, the spoof surface is the proxy, not the app.
What nginx should own, and what Node should not
TLS lives in nginx. Certificate renewal, cipher policy, and HTTP to HTTPS redirects stay there. Node keeps serving HTTP on loopback. WebSockets need an extra pair of headers (Upgrade and Connection upgrade) on the location that actually upgrades; do not sprinkle those on every location or you break keepalive. Static files that never change can be served with an nginx alias so Node does not burn the event loop on index.css. Dynamic routes stay in proxy_pass.
Rate limits, request size, and buffering also belong at the proxy. client_max_body_size in nginx is cheaper than discovering a 50 MB JSON body inside a Node middleware. proxy_buffering on is the default and is correct for ordinary HTTP APIs. Turn it off only for streaming responses you have measured.
L3/L4 DDoS filtering in front of the VPS still leaves application-layer abuse for you. nginx limit_req is the first cheap throttle. Node is the last place you want to learn that a client is flooding POST /login.
Reload the proxy, restart the app
nginx reload is cheap and preserves the listen sockets. systemctl restart nodeapp is not cheap: it drops in-flight Node requests. A later post covers socket-activated restarts. For today, change nginx config with reload, and change application code with a restart of the unit only. Do not restart nginx to pick up a new Node binary. Do not restart Node to pick up a new certificate.
If proxy_pass returns 502, Node is down or bound to the wrong address. journalctl -u nodeapp.service plus curl -sS http://127.0.0.1:3000 from the host isolate the layer in seconds. Do not debug 502 by staring at the browser.
The takeaway: nginx owns 443, Node owns loopback, and the headers in the site file are the contract between them. Keep that contract small and the rest of the stack stays boring.
Spin up Ubuntu 24.04 on Netbay, install nginx, and point it at 127.0.0.1:3000 — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance