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.

nginx WebSocket upgrade forwarding Client Upgrade request passes through nginx, which re-sets Upgrade and Connection headers over HTTP/1.1 to the backend, which replies 101 Switching Protocols. Browser Upgrade: websocket nginx http_version 1.1 Backend ws server map re-sets Connection: upgrade per request 101 Switching Protocols -> full-duplex

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 -t passes and the reload completed without dropping existing connections (nginx -s reload
  • A handshake returns 101 Switching Protocols, not 400 or 502
  • proxy_read_timeout
  • Plain HTTP requests through the same location still negotiate keepalive (no stray Connection: upgrade
  • X-Forwarded-Proto reaches the backend so origin/secure-cookie logic sees https/wss
  • Access logs record $http_upgrade so 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.

Back to WebSocket Browser Compatibility & Polyfills