Configuring nginx for WebSocket upgrades #
You put nginx in front of your real-time backend and now handshakes intermittently return 400 Bad Request, or connections drop with 502 Bad Gateway exactly 60 seconds after they open. The browser logs a close event with code 1006 (abnormal closure), state-sync payloads stop arriving, and the client falls back to polling. If you landed here from a stack trace that points at nginx rather than your app, the proxy is stripping the WebSocket upgrade — your backend never sees a valid handshake, or nginx tears the tunnel down mid-stream. This page fixes both failures with a single annotated server block.
Root cause #
There are two independent bugs that produce these symptoms, and a working config has to address both.
The handshake never upgrades. A WebSocket connection starts life as an ordinary HTTP/1.1 request carrying Upgrade: websocket and Connection: Upgrade headers. The server is expected to answer 101 Switching Protocols, after which the TCP socket is repurposed for full-duplex framing — the protocol handshake mechanics cover the byte-level exchange. nginx defaults to HTTP/1.0 on the upstream side (proxy_http_version 1.0), and HTTP/1.0 has no concept of connection upgrade. Worse, nginx is a hop-by-hop proxy: per RFC 7230, Upgrade and Connection are hop-by-hop headers that are not forwarded unless you re-set them explicitly. So your backend receives a plain GET /ws/ with no upgrade headers, rejects it, and you get a 400.
The tunnel times out. Once the upgrade does succeed, nginx treats the long-lived socket like any other upstream response and applies proxy_read_timeout, which defaults to 60 seconds. A WebSocket that is idle — waiting for the next server push — sends no bytes, so nginx considers the upstream unresponsive and closes the connection. That is the 502/1006 at the 60-second mark. Application-level ping/pong heartbeats keep the socket warm, but only if nginx’s read timeout is longer than your heartbeat interval.
The reason the Connection header needs a map directive rather than a hardcoded value: the same location may carry both upgrade requests and ordinary HTTP. Hardcoding Connection: upgrade would send that header on plain requests too, breaking HTTP/1.1 keepalive. The value must be upgrade when the client sent Upgrade, and close otherwise — exactly what map $http_upgrade computes.
Resolution #
The following server block forwards the upgrade correctly and keeps idle tunnels alive. The map lives at http scope; the proxy_* directives live in the location that handles your WebSocket path.
http {
# Computes the Connection header value per request:
# "upgrade" when the client sent Upgrade, "close" otherwise.
# Without this, hardcoding Connection breaks plain-HTTP keepalive.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close; # no Upgrade header -> ordinary request, close hop
}
upstream real_time_backend {
server 127.0.0.1:8080;
keepalive 64; # reuse upstream sockets; requires http_version 1.1
}
server {
listen 443 ssl;
server_name ws.example.com;
location /ws/ {
proxy_pass http://real_time_backend;
proxy_http_version 1.1; # 1.0 cannot upgrade
proxy_set_header Upgrade $http_upgrade; # forward hop-by-hop Upgrade
proxy_set_header Connection $connection_upgrade; # from the map above
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; # backend sees wss vs ws
proxy_connect_timeout 5s; # fail fast if backend down
proxy_read_timeout 3600s; # default 60s kills idle sockets
proxy_send_timeout 3600s; # must exceed heartbeat interval
proxy_buffering off; # stream frames, don't buffer
}
}
}
Three lines do the real work. proxy_http_version 1.1 is what makes upgrade possible at all. The two proxy_set_header lines for Upgrade and Connection re-inject the hop-by-hop headers that nginx would otherwise drop. And proxy_read_timeout 3600s is what stops the 60-second 502 — set it comfortably above your heartbeat period so a healthy-but-quiet socket is never mistaken for a dead one. proxy_buffering off ensures server pushes reach the client immediately instead of sitting in nginx’s response buffer.
If you terminate TLS at nginx and run multiple backend nodes, the same node must keep serving a given connection for the life of the socket; pair this config with load balancer sticky sessions so reconnects and any HTTP fallback land on a consistent upstream.
Operational checklist #
-
nginx -tpasses and the reload completed without dropping existing connections (nginx -s reload - A handshake returns
101 Switching Protocols, not400or502 -
proxy_read_timeout - Plain HTTP requests through the same
locationstill negotiate keepalive (no strayConnection: upgrade -
X-Forwarded-Protoreaches the backend so origin/secure-cookie logic seeshttps/wss - Access logs record
$http_upgradeso you can alert on upgrade success rate (400/502
FAQ #
Why do I get a 502 exactly 60 seconds after the connection opens? #
That is the default proxy_read_timeout. An idle WebSocket sends no upstream bytes, so nginx considers the backend unresponsive after 60s and closes the socket. Raise proxy_read_timeout (and proxy_send_timeout) above your heartbeat interval — 3600s is common for long-lived connections.
Do I need the map directive, or can I hardcode Connection: upgrade? #
Use the map. Hardcoding Connection: upgrade sends that header on every request through the location, including ordinary HTTP, which corrupts HTTP/1.1 keepalive on shared upstreams. The map emits upgrade only when the client actually sent an Upgrade header and close otherwise.
Does this work behind AWS ALB or another upstream proxy? #
Yes, but nginx is then one hop in a chain. The ALB also enforces an idle timeout (default 60s) and must itself preserve the upgrade. Align every hop’s idle timeout above your heartbeat, and confirm the ALB target group is configured for WebSocket-compatible routing.
What changes for Socket.IO instead of raw ws? #
The proxy directives are identical — Socket.IO rides the same HTTP upgrade. If you run Socket.IO without WebSocket transport it falls back to HTTP long-polling, which needs sticky routing so each poll hits the same node. The header and timeout config here is unchanged.
Why is proxy_buffering off required? #
With buffering on, nginx accumulates the upstream response before forwarding it. For a streaming socket that means server pushes are held in nginx’s buffer instead of reaching the client immediately, adding latency and breaking real-time delivery. Turning it off forwards frames as they arrive.
Related #
- Browser Compatibility & Polyfills — fallback strategies when the upgrade or proxy still fails.
- Protocol Handshake Mechanics — the byte-level upgrade exchange this config must preserve.
- Security & TLS Configuration — terminating
wss://and forwarding scheme correctly. - Load Balancer Sticky Sessions — keeping a connection pinned to one upstream node.
- Connection Lifecycle & Heartbeats — heartbeat intervals that must stay under the read timeout.