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 #

Four directives account for essentially every nginx WebSocket failure, and each is a default that is correct for ordinary HTTP.

The four settings, and what each default costs you A table of the four nginx directives that must change for WebSocket proxying, their defaults, the values required, and the symptom each default produces. The four settings, and what each default costs you Default Required Symptom proxy_http_version 1.0 1.1 200 instead of 101 Upgrade header not forwarded $http_upgrade no upgrade attempted Connection header stripped $connection_upgrade 400 from backend proxy_read_timeout 60s > heartbeat 1006 at 60s idle Connection is a hop-by-hop header, so nginx strips it correctly per the HTTP specification — you must re-add it deliberately
None of these are bugs. All four are defaults written for request and response traffic.
Which timeout governs which phase Different nginx timeouts govern the upgrade request, the upstream connection, the live tunnel in each direction, and how long a reload lets old workers keep serving. Which timeout governs which phase client_header_timeout the upgrade request must arrive 60s proxy_connect_timeout nginx to upstream TCP connect 60s proxy_read_timeout governs the live tunnel — the one that matters 3600s proxy_send_timeout outbound writes to the client 3600s worker_shutdown_timeout how long a reload keeps old tunnels alive 1h Only the middle two apply once the connection is upgraded — everything else is about getting there
The third band is the one that kills healthy connections at sixty seconds when left at its default.

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.

Why does the map block have to be in the http context? #

Because map defines a variable at configuration-parse time and nginx only allows it at the http level — placing it inside server or location is a syntax error. That is also why the same map can serve every server block in the file: it is evaluated per request against $http_upgrade, so one definition correctly handles both upgrade and non-upgrade traffic everywhere.

Does proxy_buffering off matter after the upgrade? #

No. Once nginx has relayed the 101 it switches to a bidirectional tunnel and buffering settings no longer apply to the frames. It still matters for the handshake response and for any streaming HTTP served from the same location, so turning it off in a WebSocket location is harmless and removes one variable when debugging latency.

What breaks when there is a second proxy in front of nginx? #

Its idle timeout, which nginx cannot override. A cloud load balancer or CDN in front applies its own limit — often 60 seconds — so raising proxy_read_timeout alone changes nothing. The durable fix is an application heartbeat shorter than every timeout in the path, because that keeps the connection non-idle for all of them at once rather than requiring each to be found and raised.

Back to WebSocket Browser Compatibility & Polyfills