WebSockets power a lot of what feels “live” on the modern web — chat apps, real-time notifications, collaborative editing, live dashboards, multiplayer games. If you’re running a WebSocket server behind Nginx (which is extremely common, since you usually want Nginx handling TLS termination, load balancing, and routing in front of your actual app), there’s a specific set of configuration details you need to get right, or connections will fail or drop unexpectedly.
I’ll walk through exactly why WebSockets need special handling in a proxy context, how to configure it correctly, and how to debug it when it doesn’t work.
Why WebSockets Need Special Proxy Configuration
A WebSocket connection starts life as a regular HTTP request. The client sends an HTTP GET request with specific headers — Upgrade: websocket and Connection: Upgrade — asking the server to switch protocols. If the server agrees, it responds with 101 Switching Protocols, and from that point on, the same underlying TCP connection is used for full-duplex WebSocket communication instead of regular HTTP request/response cycles.
By default, Nginx’s proxy_pass doesn’t forward the Upgrade and Connection headers needed for this handshake, and HTTP/1.0 proxying behavior (which Nginx uses by default toward upstreams) doesn’t support the Upgrade mechanism at all. Without explicit configuration, your WebSocket handshake will simply fail, or the connection will be dropped shortly after appearing to work.
Requirements
- Nginx installed and already proxying regular HTTP traffic to your backend application.
- A WebSocket server running somewhere Nginx can reach (Node.js with
wsorsocket.io, a Python server usingwebsockets, a Go server, etc.). - Nginx version 1.3.13 or later — WebSocket proxying support was added in that release, though virtually every currently maintained version is well past this.
Check your version:
nginx -v
The Core Configuration
Here’s the minimum required configuration to proxy WebSocket connections correctly:
location /ws/ {
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;
}
Let’s break down why each of these matters:
proxy_http_version 1.1;— WebSocket upgrades require HTTP/1.1. Nginx defaults to HTTP/1.0 for upstream connections unless you explicitly bump this.proxy_set_header Upgrade $http_upgrade;— forwards the client’sUpgradeheader (usuallywebsocket) through to your backend.proxy_set_header Connection "upgrade";— tells the backend this is a protocol upgrade request. Note this is often hardcoded to"upgrade"rather than dynamically forwarded, though a more robust approach (shown below) handles this dynamically to avoid breaking regular HTTP requests on the same location.
The Dynamic Connection Header Pattern
If your location block serves both regular HTTP requests and WebSocket upgrade requests (common when a single backend exposes both a REST API and a WebSocket endpoint on overlapping paths), hardcoding Connection: upgrade unconditionally can cause subtle issues with connection reuse for normal HTTP traffic. The standard fix, straight from Nginx’s own documentation, uses a map block:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
location /ws/ {
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;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}
This way, if the client sends an Upgrade header, Nginx forwards Connection: upgrade. If it’s a normal HTTP request with no Upgrade header ($http_upgrade is empty), Nginx sends Connection: close instead, which is safer default behavior for regular proxied requests.
Handling Long-Lived Connections: Timeouts
WebSocket connections are often long-lived — sometimes staying open for hours. Nginx’s default proxy timeouts are much shorter than that, and it will silently close idle connections that exceed them, which looks like a random disconnect to your users.
location /ws/ {
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;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
}
proxy_read_timeout— how long Nginx waits for data from the upstream before considering the connection dead. The default is 60 seconds, which is far too short for a WebSocket connection that might sit idle between messages.proxy_send_timeout— similarly, how long Nginx waits when sending data to the upstream.
An hour (3600s) is a common starting point; adjust based on your application’s actual idle behavior. Some people set this much higher (or effectively unlimited) for persistent connections, relying on application-level heartbeats/pings instead to detect and clean up genuinely dead connections.
Application-Level Heartbeats
Even with generous Nginx timeouts, I strongly recommend implementing ping/pong heartbeats at the WebSocket application layer (most WebSocket libraries support this natively). This keeps the connection demonstrably alive, gives you a clean way to detect and clean up genuinely dead connections (like a client that lost network without a proper close handshake), and works around any intermediate timeout — including ones you don’t control, like corporate proxies or mobile carrier NAT tables between the client and your server.
Load Balancing WebSocket Connections
If you’re running multiple backend WebSocket servers behind Nginx, you generally need sticky sessions (also called session affinity), since a WebSocket connection stays pinned to whichever backend instance accepted the original handshake — you can’t transparently move an established WebSocket connection to a different backend mid-session the way you might with stateless HTTP requests.
upstream websocket_backend {
ip_hash;
server 10.0.0.10:3000;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
server {
listen 80;
server_name example.com;
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
}
}
ip_hash routes a given client IP consistently to the same backend, which works reasonably well for sticky sessions in simple deployments. For more robust session affinity — especially behind NAT where many clients share one IP — consider a solution that uses cookies or, if you’re running a message broker (like Redis pub/sub) shared across backend instances, you may not need sticky sessions at all, since any backend can handle any client and broadcast messages through the shared broker.
Complete Example Configuration
Here’s a full setup combining TLS termination, dynamic connection header handling, and reasonable timeouts — a realistic production configuration for something like a chat application:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream chat_backend {
ip_hash;
server 10.0.0.10:3000;
server 10.0.0.11:3000;
}
server {
listen 80;
server_name chat.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name chat.example.com;
ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
location /socket.io/ {
proxy_pass http://chat_backend;
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_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
root /var/www/chat.example.com;
try_files $uri $uri/ /index.html;
}
}
}
Testing Your Configuration
Start with basic syntax validation:
sudo nginx -t
sudo systemctl reload nginx
You can test a WebSocket handshake directly with curl, which supports sending the upgrade headers manually:
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://example.com/ws/
A successful proxy configuration returns HTTP/1.1 101 Switching Protocols. If you instead get a 200, 400, or 502, something’s wrong with either the upgrade headers or the connection to your backend.
For more thorough testing, browser DevTools are genuinely the best tool. Open the Network tab, filter by “WS,” and connect to your WebSocket endpoint from your actual frontend. You’ll see the handshake request/response, and you can inspect individual frames being sent and received in real time.
Command-line WebSocket clients like websocat are also useful for quick manual testing:
websocat wss://chat.example.com/socket.io/
Troubleshooting Common Issues
Handshake fails with a 400 or 426 error. Check that proxy_http_version 1.1; is set — without it, Nginx can’t properly relay the upgrade handshake. Also confirm the Upgrade and Connection headers are actually being forwarded correctly, not just set to static placeholder values that don’t match what the client sent.
Connection establishes but drops after ~60 seconds of inactivity. This is almost always the default proxy_read_timeout or proxy_send_timeout kicking in. Increase both, and consider adding application-level pings as a more robust long-term fix.
Works fine with one backend but breaks when load balancing across multiple. You’re likely missing sticky sessions. Add ip_hash; to your upstream block, or migrate to a shared-state architecture (like Redis pub/sub) that doesn’t require session affinity.
Works over HTTP but fails once TLS/WSS is involved. Make sure your frontend is actually connecting via wss:// (not ws://) when the page itself is served over HTTPS — browsers block insecure WebSocket connections from secure pages (mixed content). Also double check your certificate covers the exact hostname used for the WebSocket endpoint.
Intermittent disconnects that seem random. This is frequently caused by an intermediate layer with its own idle timeout — a corporate firewall, a mobile carrier’s NAT table, or a cloud load balancer sitting in front of Nginx with a shorter timeout than Nginx itself. Application-level heartbeats sent every 20-30 seconds are the most reliable fix here, since they keep the connection demonstrably active at every layer.
Security Considerations
- Always terminate WebSocket connections over TLS (
wss://) in production — plainws://sends everything, including any auth tokens exchanged over the socket, in cleartext. - Validate the
Originheader on your WebSocket server (not just Nginx) to prevent cross-site WebSocket hijacking, where a malicious page could otherwise open a WebSocket connection to your server using a logged-in user’s cookies. - Apply rate limiting on the initial HTTP upgrade request to prevent connection-flood style abuse:
limit_req_zone $binary_remote_addr zone=ws_limit:10m rate=5r/s;
location /ws/ {
limit_req zone=ws_limit burst=10 nodelay;
...
}
- Authenticate WebSocket connections properly — typically via a token passed during the handshake (as a query parameter or subprotocol) validated by your backend, since WebSocket connections don’t carry standard HTTP auth headers on every “request” the way REST APIs do after the initial handshake.
Performance Tips
- Keep
proxy_buffering off;for WebSocket locations if you notice message delivery latency — Nginx’s default buffering behavior is tuned for regular HTTP responses and can introduce unwanted delay for real-time bidirectional traffic. - Monitor open connection counts per worker; WebSocket connections stay open far longer than typical HTTP requests, so
worker_connectionsneeds to be sized generously if you expect many concurrent connections. - If running at meaningful scale, consider a message broker (Redis, NATS) to decouple WebSocket delivery from application logic, which also removes the sticky-session requirement entirely.
Real-World Use Cases
- Chat and messaging applications — the textbook WebSocket use case.
- Live notifications and activity feeds — pushing updates to a dashboard without polling.
- Collaborative editing tools — real-time cursor positions and document changes, like Google Docs-style collaboration.
- Live sports scores and financial tickers — low-latency data feeds pushed to many simultaneous viewers.
- Multiplayer browser games — real-time state synchronization between players.
Best Practices
- Always set
proxy_http_version 1.1;and the dynamicConnectionheader pattern together — don’t hardcodeConnection: upgradeunconditionally on a shared location. - Generously extend
proxy_read_timeout/proxy_send_timeoutfor WebSocket locations specifically, rather than globally, so you don’t accidentally mask issues on regular HTTP endpoints. - Implement application-level heartbeats regardless of how generous your Nginx timeouts are.
- Use
ip_hashor a shared-state backend architecture when load balancing across multiple WebSocket servers. - Test with real browser DevTools, not just
curl, before considering your setup production-ready — some issues only surface with genuine bidirectional traffic. - Isolate WebSocket locations into their own
locationblocks with their own tuned timeout and buffering settings, rather than mixing them into general-purpose proxy blocks.
Getting WebSocket proxying right through Nginx is mostly about a handful of specific directives that are easy to forget if you’re used to configuring plain HTTP reverse proxies. Once they’re in place, though, it’s a solid, battle-tested setup that handles real-time traffic reliably at scale.