You deploy a chat app, a live dashboard, or a Socket.io-powered feature behind Nginx on your VPS, and it works fine on localhost. Push it live behind the reverse proxy and the WebSocket connection either refuses to upgrade, drops every 60 seconds, or falls back to long-polling and never recovers. If you've been staring at browser dev tools wondering why the handshake keeps failing, this one's for you.

Symptom: WebSocket Handshake Fails or Connection Keeps Dropping

A few ways this shows up in the wild:

  • Browser console shows WebSocket connection to 'wss://yourdomain.com/socket' failed: Error during WebSocket handshake: Unexpected response code: 400
  • The connection opens, then closes with code 1006 after roughly a minute of inactivity
  • Socket.io silently falls back to HTTP long-polling and your "real-time" dashboard updates every few seconds instead of instantly
  • Everything works over plain HTTP on port 3000 but breaks the moment you put Nginx in front of it on 443

Cause: Nginx Doesn't Upgrade HTTP Connections by Default

A WebSocket starts life as a normal HTTP request with two special headers — Upgrade: websocket and Connection: Upgrade — that tell the server "keep this TCP connection open and switch protocols." Nginx's default proxy behavior doesn't forward those headers unless you explicitly tell it to. Without them, your Node.js/Django/Rails app never sees the upgrade request, so it responds like it's just another HTTP call — and the handshake fails.

Even after you fix the headers, two more things commonly bite people:

  1. Idle timeouts. Nginx closes proxied connections after proxy_read_timeout (default 60s) of inactivity. A WebSocket that isn't actively pinging looks "idle" to Nginx and gets killed.
  2. Load balancing without sticky sessions. If you're running more than one app instance behind Nginx (or using PM2 cluster mode), a reconnect can land on a different worker that has no memory of the original session — Socket.io in particular needs sticky sessions unless you've wired up a Redis adapter.

Fix: Add the Upgrade Headers and Raise the Timeout

Open your site's Nginx config — usually /etc/nginx/conf.d/yourdomain.conf or, on a cPanel/EA-Nginx setup, the relevant *.conf include for that vhost — and update the location block your app runs behind:

location /socket.io/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header 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_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

A few things worth calling out here:

DirectiveWhy it matters
proxy_http_version 1.1WebSocket upgrades require HTTP/1.1 — Nginx defaults to 1.0 for proxied requests, which can't upgrade at all
proxy_set_header Connection "upgrade"Must be the literal string, not $http_connection — some setups get this wrong and it silently breaks reconnects
proxy_read_timeout 3600sKeeps long-lived idle connections (dashboards, chat) alive instead of getting killed at the 60s default

Test and reload after saving:

nginx -t
systemctl reload nginx

If your app sits behind Apache too (a common cPanel setup where Nginx reverse-proxies to Apache, which then hits your Node app), make sure Apache isn't buffering or stripping the same headers with mod_proxy_wstunnel disabled — enable it with a2enmod proxy_wstunnel on Debian/Ubuntu-style setups, or check httpd -M | grep wstunnel on AlmaLinux/CloudLinux boxes.

Verify the Fix From the Command Line

Before trusting the browser, confirm the handshake actually upgrades:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
  https://yourdomain.com/socket.io/

A working proxy returns HTTP/1.1 101 Switching Protocols. If you get a 400 or a normal 200 with HTML back, the upgrade headers still aren't reaching your app.

If You're Behind Cloudflare

Cloudflare supports WebSockets on all plans by default now, but if connections still fail, check Network in the Cloudflare dashboard and confirm the WebSockets toggle is on. Also make sure your DNS record for the app subdomain is proxied (orange cloud) with SSL mode set to Full (strict) — a mismatched SSL mode between Cloudflare and your origin will kill the TLS handshake before Nginx even sees the request.

Sticky Sessions for Multi-Instance Apps

Running Socket.io across multiple Node processes (PM2 cluster mode or several VPS backends behind Nginx upstream)? Add ip_hash so a client always lands on the same backend:

upstream socket_backend {
    ip_hash;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
}

The cleaner long-term fix is a Redis adapter (@socket.io/redis-adapter) so any instance can handle any client — but ip_hash gets you working reconnects today with zero app code changes.

Prevention

  • Bake the Upgrade headers into your Nginx template from day one for any app that might add real-time features later — it costs nothing to have them present and unused.
  • Set proxy_read_timeout deliberately based on your app's ping interval, not the Nginx default. Socket.io's default ping interval is 25s with a 20s timeout, so 3600s of Nginx idle timeout gives plenty of headroom.
  • If you scale to multiple app instances later, plan for sticky sessions or a shared adapter before you need them — retrofitting this after users start reporting "random disconnects" is a worse debugging session than doing it up front.
  • Keep a copy of the working curl handshake test handy — it's the fastest way to tell "Nginx problem" from "app problem" the next time something breaks.

Frequently Asked Questions

Do I need to change anything if my app already works fine locally without Nginx?

Yes. WebSockets work fine when your browser connects straight to Node/Django on its own port because there's no proxy stripping headers. The moment Nginx sits in front of it on 80/443, you need the Upgrade and Connection headers explicitly set in that location block.

Why does the connection work for a minute and then die?

That's almost always proxy_read_timeout hitting its default 60-second idle limit. Raise it in the location block handling your WebSocket path, not globally in http {}, so you don't accidentally keep every other proxied connection open for an hour too.

Does this apply to Apache instead of Nginx?

The concept is the same — Apache needs mod_proxy_wstunnel enabled and a ProxyPass rule using the ws:// or wss:// scheme for the WebSocket path. If you're running EA-Apache behind Nginx on a cPanel VPS, both layers need the upgrade headers passed through.

My app uses Socket.io — do I need to change the client code too?

No, if the server-side proxy config is correct, Socket.io's client will negotiate the WebSocket upgrade automatically. If it's still falling back to polling after the Nginx fix, check your firewall (CSF/UFW) isn't blocking the upgrade on the app's internal port, and confirm the path option on the client matches the Nginx location block exactly.

Is there a downside to setting proxy_read_timeout very high?

A very high value (like 24h) can let dead connections pile up and hold worker resources if clients disconnect uncleanly without closing the TCP socket. 1 hour (3600s) is a safe middle ground for most chat/dashboard use cases — tune it down if your app's own ping/pong already recycles idle connections faster.