The first time I tried to proxy a WebSocket connection through Nginx, it silently failed in the most confusing way possible — the initial HTTP handshake succeeded, but the connection would drop within seconds, and my chat application’s real-time messages just never arrived. It took me embarrassingly long to realize the issue: I’d copied a standard HTTP reverse proxy config without adding the specific headers WebSockets need for the protocol upgrade to actually persist. Once I understood why those headers matter, WebSocket proxying became straightforward. This guide walks through everything I’ve learned setting up Nginx as a WebSocket proxy — chat apps, live notifications, Socket.IO, real-time dashboards, all of it.
How WebSockets Differ from Regular HTTP
A normal HTTP request is short-lived: the client sends a request, the server sends a response, the connection closes (or gets reused briefly with keep-alive). WebSockets work differently — a client initiates the connection with a special HTTP request containing an Upgrade: websocket header. If the server agrees, the connection “upgrades” from HTTP to a persistent, full-duplex TCP connection that stays open indefinitely, allowing both sides to push data at any time without repeated request/response cycles.
Because Nginx sits between the client and your application by default, it needs explicit configuration to:
- Recognize the upgrade request
- Forward the
UpgradeandConnectionheaders correctly - Keep the underlying TCP connection open for as long as the WebSocket session lasts, rather than timing it out like a normal HTTP request
Requirements
- Nginx version 1.3.13 or later (WebSocket proxying support was added then; anything from the last several years is fine)
- A WebSocket-capable backend application (Node.js with
wsor Socket.IO, Python with Django Channels or FastAPI, etc.) - Basic reverse proxy setup already working (see the companion guide on Nginx with Node.js if you haven’t set this up yet)
Check your Nginx version:
nginx -v
Step 1: Set Up a Test WebSocket Server
To have something concrete to test against, here’s a minimal WebSocket server in Node.js using the ws package:
mkdir ~/wstest && cd ~/wstest
npm init -y
npm install ws
server.js:
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3001, host: '127.0.0.1' });
wss.on('connection', (ws) => {
console.log('Client connected');
ws.on('message', (message) => {
console.log('Received:', message.toString());
ws.send(`Echo: ${message}`);
});
ws.on('close', () => console.log('Client disconnected'));
});
console.log('WebSocket server running on ws://127.0.0.1:3001');
Run it:
node server.js
Step 2: The Core Nginx WebSocket Proxy Configuration
Here’s the essential configuration — I’ll explain every line, since each one matters:
server {
listen 80;
server_name ws.example.com;
location /ws/ {
proxy_pass http://127.0.0.1:3001/;
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;
}
}
Breaking down the WebSocket-specific pieces:
proxy_http_version 1.1;— WebSockets require HTTP/1.1. Nginx defaults to HTTP/1.0 for proxied requests, which doesn’t support theUpgrademechanism at all. This line is mandatory.proxy_set_header Upgrade $http_upgrade;— Forwards the client’sUpgrade: websocketheader through to your backend. Without this, the backend has no idea the client wants to upgrade the connection.proxy_set_header Connection "upgrade";— Tells the backend the connection should be treated as an upgrade request. This is often hardcoded as the string"upgrade"rather than a variable, since it needs to be present specifically for upgrade requests.proxy_read_timeout/proxy_send_timeout— By default, Nginx closes idle proxied connections after 60 seconds. WebSocket connections are often idle between messages (think of a chat app where nobody’s typing), so without extending these timeouts, Nginx will silently kill the connection out from under your app, causing exactly the “connects then drops” symptom I described earlier. I set this to an hour (3600s) as a sane default, though some apps benefit from even longer values.
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Step 3: Testing the WebSocket Proxy
I use wscat for quick manual testing:
npm install -g wscat
wscat -c ws://ws.example.com/ws/
If everything’s working, you’ll get a connected prompt. Type a message and you should see the echo response come back from your test server. If the connection drops immediately, revisit the Nginx config — it’s almost always a missing Upgrade/Connection header or an HTTP version issue.
You can also test directly with curl to inspect the handshake headers:
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://ws.example.com/ws/
A successful handshake response includes HTTP/1.1 101 Switching Protocols — that’s the confirmation Nginx and your backend correctly negotiated the WebSocket upgrade.
Step 4: WebSockets Over HTTPS (WSS)
In production, you’ll almost always want encrypted WebSocket connections (wss://), which just means running your WebSocket proxy behind an SSL-terminated server block:
server {
listen 443 ssl;
server_name ws.example.com;
ssl_certificate /etc/letsencrypt/live/ws.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ws.example.com/privkey.pem;
location /ws/ {
proxy_pass http://127.0.0.1:3001/;
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;
}
}
server {
listen 80;
server_name ws.example.com;
return 301 https://$host$request_uri;
}
The client would then connect using wss://ws.example.com/ws/ instead of ws://. Nginx handles the TLS termination exactly the same way it does for regular HTTPS traffic — your backend WebSocket server itself doesn’t need to know anything about TLS.
Step 5: Handling Both HTTP and WebSocket Traffic on the Same Server
Frequently, your app serves regular HTTP requests (a REST API, a web UI) and a WebSocket endpoint on the same domain. Here’s how I structure that:
server {
listen 443 ssl;
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;
# Regular HTTP traffic
location / {
proxy_pass http://127.0.0.1:3000;
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 traffic
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;
}
}
I used /socket.io/ here since that’s Socket.IO’s default namespace path — adjust to match whatever path your WebSocket library actually uses.
Step 6: Using a Map Directive for Dynamic Connection Header
If you have mixed traffic where some requests are upgrades and some aren’t within the same location block, Nginx’s official recommended pattern uses a map directive to set the Connection header conditionally:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
This sets Connection: upgrade only when the client actually sent an Upgrade header, and Connection: close otherwise — this is more correct than hardcoding "upgrade" for every request if that location also handles regular HTTP traffic.
Step 7: Load Balancing WebSocket Connections
If you’re running multiple backend instances, there’s an important nuance: WebSocket connections are stateful and long-lived, so once a client connects to a specific backend instance, all its messages need to keep routing to that same instance (unless your app has a shared state layer like Redis pub/sub for cross-instance broadcasting).
upstream websocket_backend {
ip_hash;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
server {
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
ip_hash ensures a given client IP consistently routes to the same backend instance, which avoids issues where a reconnect lands on a different server that has no knowledge of the client’s session state. For apps using Socket.IO with Redis adapter (which shares state across instances), you can skip ip_hash and use round-robin (least_conn or default) instead, since any instance can serve any client.
Troubleshooting Common Issues
Connection upgrades but drops after ~60 seconds — Classic symptom of default proxy_read_timeout/proxy_send_timeout (60s) being hit during an idle period. Increase both as shown above.
“Error during WebSocket handshake: Unexpected response code: 400” — Usually means proxy_http_version 1.1; is missing, or the Upgrade/Connection headers aren’t being forwarded.
Works locally, fails through a CDN/load balancer in front of Nginx — Some CDNs and load balancers need explicit WebSocket support enabled (Cloudflare requires it turned on per-domain, for example) or don’t support them on certain plan tiers. Check your CDN’s documentation specifically for WebSocket pass-through.
Reconnects randomly hit a different backend and lose session state — Missing ip_hash (or equivalent session affinity) in a multi-backend upstream block — see Step 7.
Nginx error log shows “upstream sent too big header” — Increase buffer sizes:
proxy_buffer_size 8k;
proxy_buffers 8 8k;
Security Considerations
- Always use WSS (WebSocket over TLS) in production, not plain
ws://, for the same reasons you’d use HTTPS for regular traffic — WebSocket payloads are otherwise sent unencrypted. - Validate the
Originheader in your application to prevent unauthorized cross-origin WebSocket connections, since browsers don’t enforce same-origin policy on WebSocket connections the way they do for typical AJAX requests. - Apply rate limiting to the initial handshake request to prevent connection-flood style abuse (see the companion rate limiting guide —
limit_connis particularly relevant here, since it limits concurrent connections, not just request rate). - Set reasonable timeout ceilings. While you want to avoid premature disconnects, an unbounded timeout can be abused to hold connections open indefinitely for resource exhaustion attacks — I generally cap at a few hours rather than setting it to something enormous.
Performance Tips
- Increase worker connections if you expect many concurrent WebSocket connections, since each one holds a connection slot open for its duration:
events {
worker_connections 4096;
}
- Monitor open connection counts —
ss -sornginx -Vcombined with the stub_status module can help you keep an eye on how many concurrent WebSocket connections are active. - Use
least_conninstead of round-robin for WebSocket load balancing where session affinity isn’t required, since it more evenly distributes long-lived connections rather than just alternating incoming requests.
Real-World Use Cases
- Live chat and messaging applications — the original use case I built this config for, still one of the most common.
- Real-time dashboards — pushing metrics/updates to a monitoring UI without polling.
- Collaborative editing tools — cursor positions and document changes broadcast in real time (Google Docs-style apps).
- Live notifications — order status updates, live sports scores, stock tickers.
- Multiplayer game state synchronization — for browser-based games needing low-latency bidirectional communication.
Best Practices I Follow
- Always set
proxy_http_version 1.1;— WebSockets simply won’t work without it. - Use the
mapdirective pattern forConnectionheader handling on locations that serve mixed HTTP and WebSocket traffic. - Extend
proxy_read_timeoutandproxy_send_timeoutwell beyond the 60-second default, but keep them bounded rather than unlimited. - Terminate TLS at Nginx and use WSS in any production deployment.
- Use
ip_hash(or an equivalent session-affinity mechanism) when load balancing across multiple stateful WebSocket backend instances. - Validate
Originheaders at the application layer as a defense against cross-origin WebSocket abuse. - Separate WebSocket and regular HTTP
locationblocks clearly, even when they share a backend, for config readability. - Load test WebSocket connections specifically — regular HTTP load testing tools often don’t exercise the upgrade path correctly.
Wrapping Up
WebSocket proxying through Nginx is one of those things that either works perfectly once configured correctly, or fails in genuinely confusing ways if you miss one of the handful of required directives. The core requirements really do boil down to just a few lines — HTTP/1.1, the Upgrade and Connection headers, and sensible timeouts — but each one is load-bearing. If you’re setting this up for the first time, I’d recommend testing with wscat at every step rather than jumping straight to your full application, so you can isolate whether an issue is in your Nginx config or somewhere in your app’s own WebSocket handling.