Your WebSocket connection drops dead after exactly 60 seconds, or your client throws an instant 400 Bad Request during the handshake. I ran into this head-first while shipping a live event feed on Node.js behind Nginx. Standard REST endpoints answered fine, but every WebSocket connection failed quietly before transferring a single frame.
Out of the box, Nginx behaves like an HTTP/1.0 reverse proxy. It strips the hop-by-hop headers WebSockets rely on to negotiate a connection. Here is how to wire up an Ubuntu box running Node.js 20 LTS, keep it running with PM2, and configure Nginx so sockets stay open without dropping out.

Why Nginx Breaks WebSockets by Default
A WebSocket connection starts as an HTTP request carrying two critical headers: Upgrade: websocket and Connection: Upgrade. If your reverse proxy doesn’t forward them intact, Node sees a regular GET request, can’t establish the TCP tunnel, and bails.
Nginx follows the HTTP/1.1 spec by stripping hop-by-hop headers. Throw a standard proxy_pass http://localhost:3000; into your config, and Nginx immediately injects Connection: close while dropping Upgrade on the floor.
Then there’s the idle timeout gotcha. If your client stops sending frames for 60 seconds, Nginx hits its default proxy_read_timeout and closes the TCP connection behind your app’s back. Fixing this takes explicit header mapping, proper timeouts matched to a heartbeat, and rock-solid process supervision in PM2.
Step 1: Build a Reproducible Node.js WebSocket Server
Before tweaking proxy rules, get a bare-bones server running locally that logs upgrades so you can actually tell what’s breaking. I use the ws package alongside Node’s built-in HTTP module to handle both REST routes and persistent sockets.
Create a directory on your server and pull in the dependencies:
mkdir -p /var/www/realtime-app
cd /var/www/realtime-app
npm init -y
npm install ws express dotenvAdd this entrypoint in server.js. It listens on a local loopback port and answers both healthchecks and socket connections:
const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws'); const app = express();
const PORT = process.env.PORT || 3001; app.get('/health', (req, res) => { res.status(200).json({ status: 'ok', uptime: process.uptime() });
}); const server = http.createServer(app);
const wss = new WebSocketServer({ server }); wss.on('connection', (ws, req) => { const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; console.log(`[ws] Client connected from ${clientIp}`); ws.send(JSON.stringify({ message: 'Connected to Node.js backend' })); ws.on('message', (data) => { console.log(`[ws] Received: ${data}`); ws.send(`Echo: ${data}`); }); ws.on('close', (code, reason) => { console.log(`[ws] Disconnected: code ${code}`); });
}); server.listen(PORT, '127.0.0.1', () => { console.log(`HTTP/WS server running on http://127.0.0.1:${PORT}`);
});Notice the server binds strictly to 127.0.0.1 instead of 0.0.0.0. Don’t expose this backend port to the public internet—lock it to localhost so all incoming traffic has to go through Nginx for SSL termination and filtering.
Step 2: Manage the Node.js Process with PM2
A bare node server.js command will crash on the first uncaught error and stay down after a reboot. PM2 keeps the process running in the background and restarts it automatically.
If you’ve followed our walkthrough to deploy Next.js on a VPS with PM2, you know that keeping an ecosystem config file saves headaches down the line. Drop an ecosystem.config.js inside /var/www/realtime-app:
module.exports = { apps: [ { name: 'realtime-backend', script: './server.js', instances: 1, exec_mode: 'fork', autorestart: true, watch: false, max_memory_restart: '300M', env: { NODE_ENV: 'production', PORT: 3001 } } ]
};A quick heads-up on exec_mode: 'fork' versus cluster mode: standard stateful WebSockets break across clustered processes unless you share session state with an adapter or set up sticky sessions. If you plan to scale across multiple CPU cores, check the PM2 Cluster Mode documentation first to see how socket state behaves across worker forks. For a single backend instance, stick with fork to avoid handshake routing issues.
Start the app and configure PM2 to resurrect it on system boots:
pm2 start ecosystem.config.js
pm2 save
pm2 startupRun pm2 status to confirm. You should see realtime-backend listed as online on port 3001.
Step 3: Define Connection Upgrades in Nginx
To upgrade connections reliably, Nginx needs to inspect the incoming Upgrade header and set the Connection header dynamically. If the client doesn’t send an Upgrade header, Nginx should pass Connection: close (or keep-alive). If it does send Upgrade: websocket, Nginx needs to pass Connection: "upgrade".
Handle this with a map directive inside the main Nginx config. Open /etc/nginx/nginx.conf:
sudo nano /etc/nginx/nginx.confInside the http { ... } block, add this mapping near the top:
map $http_upgrade $connection_upgrade { default upgrade; '' close;
}This evaluates $http_upgrade. If it contains a value (like websocket), Nginx sets our custom $connection_upgrade variable to upgrade. If the header is absent, it falls back to close. This lets your standard REST endpoints and WebSocket upgrades share the same reverse proxy blocks cleanly.
Step 4: Configure the Reverse Proxy Server Block
Now create your application’s server block in /etc/nginx/sites-available/realtime.example.com:
upstream nodejs_backend { server 127.0.0.1:3001; keepalive 32;
} server { listen 80; server_name realtime.example.com; access_log /var/log/nginx/realtime_access.log; error_log /var/log/nginx/realtime_error.log warn; location / { proxy_pass http://nodejs_backend; proxy_http_version 1.1; # Upgrade headers proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; # Standard forwarding headers 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; # WebSocket timeout configurations proxy_read_timeout 3600s; proxy_send_timeout 3600s; # Disable buffering to preserve event flow proxy_buffering off; }
}Here is what each of those proxy lines is doing:
proxy_http_version 1.1;: Required. HTTP/1.0 does not support protocol upgrades or persistent streams.proxy_set_header Upgrade $http_upgrade;: Passes the upgrade token straight to Node.js.proxy_set_header Connection $connection_upgrade;: Passesupgradeorclosedynamically based on our map block.proxy_read_timeout 3600s;: Stops Nginx from killing quiet TCP connections after its default 60-second inactivity limit.proxy_buffering off;: Disables response buffering so frames reach the client immediately without proxy delays.
Enable the site and test your syntax before reloading:
sudo ln -s /etc/nginx/sites-available/realtime.example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxIf you’re tuning proxy performance further, see how we serve pre-compressed assets on Nginx to keep static traffic off your Node thread entirely.
Step 5: Enforce Secure WebSockets (WSS) via Let’s Encrypt
Modern browsers block unencrypted ws:// links outright from any page served over HTTPS due to mixed-content security rules. You need TLS terminated at Nginx and a working wss:// endpoint.
Pull an SSL certificate using Certbot with the Nginx plugin:
sudo apt update && sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d realtime.example.comCertbot updates your config to listen on port 443 with modern SSL defaults. If you’re running complex setups with internal services, look into wildcard subdomains with Certbot DNS challenges to streamline cert management.
Once Certbot finishes, open your config and double-check that your new server { listen 443 ssl; ... } block kept the proxy_set_header Upgrade and proxy_read_timeout directives we added earlier.
Step 6: Verify and Debug the WebSocket Handshake
Don’t wait for your frontend app to throw error toasts. Test the upgrade handshake straight from your terminal using curl:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Host: realtime.example.com" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" -H "Sec-WebSocket-Version: 13" https://realtime.example.com/If Nginx and Node are talking correctly, you’ll get an HTTP 101 response back:
HTTP/1.1 101 Switching Protocols
Server: nginx/1.24.0
Date: Mon, 14 Apr 2025 10:14:22 GMT
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: icVu+v03N3A6avnpxqFFdT2Nnls=If you see HTTP/1.1 200 OK or 400 Bad Request, Nginx dropped either Upgrade or Connection before the packet reached Node. Check the Nginx WebSocket documentation if you’re dealing with unusual subpaths.
Next, install wscat to open an interactive session and test bidirectional messaging:
npm install -g wscat
wscat -c wss://realtime.example.com/Type a message and hit Enter. You should get the echo back immediately with no delay and no drops.
Gotchas I Fixed in Production
These two issues caught me off guard after pushing this stack to production.
1. The 60-Second Phantom Disconnect
Even with proxy_read_timeout 3600s;, middleboxes like mobile carrier networks, corporate proxies, and NAT firewalls will drop idle TCP connections after 30 to 60 seconds of dead silence. If neither side transmits, the socket silently dies without ever sending a FIN packet.
The fix isn’t making Nginx timeouts longer—it’s running an application-level heartbeat. Ping clients every 30 seconds from your Node server:
function heartbeat() { this.isAlive = true;
} wss.on('connection', (ws) => { ws.isAlive = true; ws.on('pong', heartbeat);
}); const interval = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { return ws.terminate(); } ws.isAlive = false; ws.ping(); });
}, 30000); wss.on('close', () => { clearInterval(interval);
});These tiny ping/pong frames reset Nginx’s proxy_read_timeout timer and stop intermediate firewalls from cutting the line.
2. Broken Handshakes in PM2 Cluster Mode
If you use libraries like Socket.io that start with HTTP long-polling before upgrading, turning on PM2 cluster mode (instances: 'max') will trigger constant 400 Session ID unknown errors.
PM2 load-balances across instances with round-robin. Request 1 (the initial handshake) hits Worker A, and Request 2 (the upgrade attempt) hits Worker B. Worker B has no idea what Worker A negotiated.
If you need cluster mode, you have two choices:
- Force pure WebSockets right away by turning off polling fallbacks in your frontend client.
- Run separate Node processes on distinct ports (3001, 3002, 3003) and use Nginx’s
ip_hash;upstream block to lock sessions to specific workers.
For secrets and config management on your box, check out our guide to deploy a Node.js app with production environment variables.
Frequently Asked Questions
Why does my WebSocket fail with a 502 Bad Gateway error?
A 502 means Nginx is running, but it can’t reach the Node process specified in proxy_pass. Run pm2 status to make sure your app hasn’t crashed, and run curl http://127.0.0.1:3001/health locally on the server to see if the port is answering.
Can I run regular REST API routes and WebSockets through the same Nginx block?
Yes. That’s why we use map $http_upgrade $connection_upgrade. When an incoming request includes the upgrade header, Nginx upgrades it. When it’s standard GET or POST traffic, Nginx passes Connection: close and handles it like an ordinary REST request.
What should proxy_read_timeout be set to?
If your app implements a 30-second ping/pong heartbeat, anywhere between 300s and 3600s is solid. Don’t set it to days without a heartbeat, or abandoned connections will hang around and leak system file descriptors.
How can I access the client’s real IP address in Node.js?
Because Nginx forwards from localhost, req.socket.remoteAddress will just return 127.0.0.1. Read the x-forwarded-for header instead. Nginx sets this automatically when you include proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;.
Next up, lock down the rest of your server stack with our guide on deploying Docker Compose on a VPS with Nginx and DNS management.

