Running node app.js on port 3000 is fine on your laptop. On a real server, it is not. You need port 80 and 443, a process that restarts after a crash or a reboot, TLS, compression, and sane timeouts. The standard answer to all of that is an Nginx reverse proxy in front of Node.js.
This guide is the full production path: install Nginx on Ubuntu, create a server block that forwards traffic from port 80 to your Node process on 127.0.0.1:3000, keep the app alive with PM2, add WebSocket support, fix the classic 502 Bad Gateway, and finish with a free Let’s Encrypt certificate via Certbot. Everything below has been tested on Ubuntu 24.04 LTS and works identically on 22.04 and 26.04.
Why put Nginx in front of Node.js at all?
Node can listen on port 80 directly. It just should not. Here is what the proxy layer buys you:
- No root privileges for your app. Ports below 1024 require elevated rights. Nginx handles that, your Node process stays unprivileged on 3000.
- TLS termination in one place. One certificate, one renewal cron, no TLS code in your app.
- Static files served by C, not JavaScript. Nginx serves assets far faster and frees the event loop.
- Multiple apps, one IP. api.example.com on 3000, app.example.com on 4000, same server.
- A buffer against slow clients. Nginx absorbs slow uploads and slow readers so your single-threaded Node process is not held hostage.
- Rate limiting, gzip/brotli, caching, access logs, maintenance pages. Config, not code.
The traffic flow in one line
Browser :443 / :80 → Nginx (public IP) → http://127.0.0.1:3000 → Node.js (PM2)

Prerequisites
- An Ubuntu server (24.04 LTS recommended) with a sudo user.
- A domain name with an A record pointing to the server IP. Replace
example.comeverywhere below. - Ports 80 and 443 open at the cloud provider firewall level (AWS security group, Hetzner firewall, etc.).
Start with a clean update:
sudo apt update && sudo apt upgrade -y
Step 1: Install Node.js
The version in the default Ubuntu repository is usually old. Use the NodeSource repository for a current LTS release:
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node -v
npm -v
If you juggle several Node versions, nvm works too, but remember that PM2 started under nvm needs the right PATH in its startup script (covered in Step 3).
Step 2: A Node.js app that listens on 127.0.0.1:3000
This detail matters more than people think. Bind to the loopback interface, not 0.0.0.0. If your app listens on all interfaces, anyone can hit http://your-ip:3000 and bypass Nginx, your rate limits and your TLS entirely.
mkdir -p /var/www/myapp && cd /var/www/myapp
npm init -y
npm install express
server.js:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
// Trust the proxy so req.ip and req.protocol are correct behind Nginx
app.set('trust proxy', 1);
app.get('/', (req, res) => {
res.send('Hello from Node behind Nginx. Client IP: ' + req.ip);
});
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.listen(PORT, '127.0.0.1', () => {
console.log('App listening on 127.0.0.1:' + PORT);
});
Test it locally:
node server.js
# in another shell
curl http://127.0.0.1:3000
If curl returns the message, the application side is done. Stop it with Ctrl+C.

Step 3: Keep the app alive with PM2
A reverse proxy is useless if the upstream dies at 3 a.m. PM2 restarts the process on crash, on reboot, and gives you logs and clustering.
sudo npm install -g pm2
cd /var/www/myapp
pm2 start server.js --name myapp
pm2 status
Use the cluster mode on multi-core servers
pm2 delete myapp
pm2 start server.js --name myapp -i max
-i max spawns one worker per CPU core and load balances between them, while still exposing a single port 3000 to Nginx.
Survive a reboot
pm2 save
pm2 startup systemd
# copy and run the sudo command that PM2 prints, then:
pm2 save
An ecosystem file is cleaner for real deployments. Create ecosystem.config.js:
module.exports = {
apps: [{
name: 'myapp',
script: './server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
},
max_memory_restart: '400M'
}]
};
pm2 start ecosystem.config.js && pm2 save
Step 4: Install Nginx and open the firewall
sudo apt install -y nginx
sudo systemctl enable --now nginx
systemctl status nginx
If UFW is active:
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status
Nginx Full opens both 80 and 443. Visiting your server IP should now show the default Welcome to nginx page.
Step 5: Create the reverse proxy server block
Never edit the default file. Create your own site file:
sudo nano /etc/nginx/sites-available/myapp
Paste this minimal but production-correct configuration:
upstream myapp_upstream {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# Max upload size, raise if you accept file uploads
client_max_body_size 20M;
access_log /var/log/nginx/myapp.access.log;
error_log /var/log/nginx/myapp.error.log;
location / {
proxy_pass http://myapp_upstream;
proxy_http_version 1.1;
# WebSocket and connection upgrade support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Forward the real client information
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_cache_bypass $http_upgrade;
proxy_redirect off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
The map block that makes WebSockets actually work
The variable $connection_upgrade does not exist by default. Hardcoding Connection "upgrade" breaks plain HTTP keepalive, so define a map once in the http context:
sudo nano /etc/nginx/conf.d/websocket.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
This sends Connection: upgrade only when the client actually requests an upgrade, and Connection: close otherwise. Socket.IO, ws, GraphQL subscriptions and Server-Sent Events all depend on it.
Step 6: Enable the site and reload
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Always run nginx -t before reloading. It catches syntax errors before they take your site down. Now open http://example.com: you should see the response from your Node app, served on port 80.

Step 7: Add HTTPS with Certbot
Let’s Encrypt is free and Certbot rewrites your server block automatically. The snap package is the version Certbot upstream supports:
sudo snap install core && sudo snap refresh core
sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo certbot --nginx -d example.com -d www.example.com
Choose the redirect option when asked. Certbot will:
- Validate ownership over port 80 (so your DNS must already resolve and port 80 must be open).
- Add a
listen 443 sslblock with the certificate paths. - Add a 301 redirect from HTTP to HTTPS.
Check that automatic renewal is armed:
sudo systemctl list-timers | grep certbot
sudo certbot renew --dry-run
After Certbot runs, your X-Forwarded-Proto header becomes https, which is exactly why app.set('trust proxy', 1) matters: without it, Express thinks every request is insecure and redirect loops appear.
Common pitfalls and how to fix them
502 Bad Gateway: the number one issue
A 502 means Nginx could not get a valid response from the upstream. Check the error log first, it almost always names the cause:
sudo tail -f /var/log/nginx/myapp.error.log
| Log message | Real cause | Fix |
|---|---|---|
| connect() failed (111: Connection refused) | Node is not running or not on port 3000 | pm2 status, pm2 logs myapp, ss -lntp | grep 3000 |
| connect() failed (13: Permission denied) | SELinux or AppArmor blocking outbound proxy connections | On RHEL-like systems: setsebool -P httpd_can_network_connect 1 |
| upstream prematurely closed connection | App crashed mid-request or killed by OOM | Check pm2 logs and dmesg | grep -i oom |
| upstream timed out (110) | Response takes longer than proxy_read_timeout | Raise proxy_read_timeout or make the endpoint async |
| upstream sent too big header | Large cookies or JWT in headers | Add proxy_buffer_size 16k; and proxy_buffers 8 16k; |
| no live upstreams | All upstream servers marked down after failures | Fix the app, then reload Nginx |
Quick triage command sequence:
curl -I http://127.0.0.1:3000 # is Node answering at all?
pm2 status # is the process online?
sudo nginx -t # is the config valid?
sudo ss -lntp | grep -E ':80|:3000'
WebSockets connect then immediately drop
- Missing
proxy_http_version 1.1;. HTTP/1.0 cannot upgrade. - Missing the
map $http_upgrade $connection_upgradeblock. - Default
proxy_read_timeoutof 60s killing idle sockets. For long-lived connections, setproxy_read_timeout 3600s;inside the WebSocket location and send application-level pings.
A dedicated location keeps the long timeout isolated:
location /socket.io/ {
proxy_pass http://myapp_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
413 Request Entity Too Large
Nginx defaults to a 1 MB body limit. File uploads fail before they ever reach Node. Set client_max_body_size in the server block and make sure your Node body parser limit matches.
req.ip always shows 127.0.0.1
Expected, since Nginx is the client as far as Node is concerned. You already forward X-Forwarded-For; enable trust proxy in Express (or read the header manually in Fastify/Koa) and the real IP comes back.
Infinite redirect loop after enabling SSL
Your app forces HTTPS, sees req.protocol === 'http' because the proxy header is ignored, and redirects forever. Fix with trust proxy plus forwarding X-Forwarded-Proto $scheme.
Static assets are slow
Let Nginx serve them directly instead of proxying every PNG through Node:
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
access_log off;
add_header Cache-Control "public, immutable";
}
The complete production server block
Here is everything combined, after Certbot has done its job:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream myapp_upstream {
server 127.0.0.1:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 20M;
server_tokens off;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
gzip_min_length 1024;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
access_log /var/log/nginx/myapp.access.log;
error_log /var/log/nginx/myapp.error.log warn;
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
access_log off;
}
location / {
proxy_pass http://myapp_upstream;
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_cache_bypass $http_upgrade;
proxy_redirect off;
proxy_read_timeout 60s;
}
}
Note: since Nginx 1.25, http2 on; replaced the old listen 443 ssl http2; syntax. On Ubuntu 22.04 with an older Nginx, keep the legacy form.

Running several Node apps on the same server
Two common patterns:
| Pattern | Config | When to use |
|---|---|---|
| Subdomain per app | One server block per server_name, each proxying a different port |
Separate frontends, separate certificates, cleanest option |
| Path prefix | location /api/ { proxy_pass http://127.0.0.1:4000/; } |
Same domain, API plus SPA. Watch the trailing slash |
The trailing slash rule: proxy_pass http://127.0.0.1:4000/; strips the /api prefix before forwarding. Without the trailing slash, the full path including /api is passed through. Pick the one that matches your Express routes.
Optional: basic rate limiting
Protect login and API endpoints at the edge, before Node burns CPU on them:
# in /etc/nginx/conf.d/limits.conf
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
proxy_pass http://myapp_upstream;
# ...same proxy headers as above
}
Deployment checklist
- Node app binds to 127.0.0.1, never 0.0.0.0.
pm2 saveandpm2 startupboth executed, verified with a real reboot.nginx -tpasses, site symlinked insites-enabled, default site removed.- UFW allows Nginx Full and OpenSSH only.
map $http_upgradedefined if you use WebSockets.trust proxyenabled in the app.- Certbot installed,
certbot renew --dry-runsuccessful. - Log rotation active (Ubuntu ships it for Nginx; add
pm2 install pm2-logrotatefor PM2).
FAQ
What is an Nginx reverse proxy server?
A reverse proxy sits in front of one or more backend servers and receives client requests on their behalf. The browser talks only to Nginx; Nginx forwards the request to your Node.js process and returns the response. It centralises TLS, caching, compression, load balancing and logging, and it hides the internal topology of your infrastructure.
Do I really need Nginx, or can Node serve port 80 directly?
Node can do it technically, but you lose free TLS management, fast static file serving, buffering against slow clients, and the ability to run several apps on one machine. For anything beyond a hobby project, the proxy layer is worth the ten minutes of setup.
Why do I get a 502 Bad Gateway right after setting this up?
Nine times out of ten the Node process is not running, or it is listening on a different port or interface than what proxy_pass targets. Run pm2 status, then curl http://127.0.0.1:3000, then read /var/log/nginx/myapp.error.log. The log line names the exact reason.
Should I use PM2 or a systemd service?
Both work. PM2 is faster to set up and gives you cluster mode, log management and a process dashboard out of the box. A plain systemd unit has zero extra dependencies and integrates with journald. Many teams run PM2 for the clustering and let systemd supervise PM2 itself through pm2 startup.
Is Nginx or Traefik better for Node.js?
Nginx is the safer default on a classic VPS: huge documentation base, minimal resources, predictable config files. Traefik shines in Docker and Kubernetes environments because it discovers containers automatically and handles certificates without manual server blocks. If you deploy with PM2 on an Ubuntu VM, stay with Nginx.
How do I handle a Node app that needs long-running requests?
Raise proxy_read_timeout and proxy_send_timeout in that specific location, and disable buffering with proxy_buffering off; for streaming responses such as Server-Sent Events or AI token streams.
Does the reverse proxy break my client IP based logic?
Only if you forget to forward and trust the headers. With X-Forwarded-For set in Nginx and trust proxy enabled in Express, req.ip returns the real visitor address again.
How do I update the config without downtime?
Edit the file, run sudo nginx -t, then sudo systemctl reload nginx. A reload applies the new configuration to new connections while existing ones finish normally. Avoid restart on a live site.
Wrapping up
You now have a Node.js app behind an Nginx reverse proxy on Ubuntu: port 80 and 443 handled by Nginx, traffic forwarded to 127.0.0.1:3000, PM2 keeping the process alive across crashes and reboots, WebSocket upgrades working, and a Let’s Encrypt certificate renewing itself. The next natural steps are adding a staging server block, piping access logs into a monitoring stack, and putting a CDN in front of your static assets.
