The Problem
Regular HTTP traffic flows through your Nginx reverse proxy without a hitch. Then a client tries to open a WebSocket connection โ and it immediately fails. The browser console shows a 400 Bad Request or a stalled WebSocket handshake, while Nginx logs this:
2024/01/15 10:23:41 [error] 1234#1234: *56 upstream prematurely closed connection while reading response header from upstream, client: 203.0.113.10, server: example.com, request: "GET /ws HTTP/1.1", upstream: "http://127.0.0.1:3000/ws"
Your backend โ a Node.js/Socket.io server, a Go WebSocket service, a Python websockets app โ accepts direct connections just fine. Nginx is the culprit, not your backend.
Root Cause
A WebSocket connection starts as a standard HTTP/1.1 request carrying two critical headers:
Upgrade: websocketโ tells the server the client wants to switch protocolsConnection: Upgradeโ signals that theUpgradeheader must be processed
Nginx silently strips these before forwarding upstream. They're called hop-by-hop headers, and Nginx drops them by design. The upstream server gets a plain GET request with zero upgrade headers. It has no idea a protocol switch was requested, so it either closes the connection or sends a response Nginx can't parse. That's what triggers upstream prematurely closed connection.
The 400 Bad Request the browser sees is Nginx's error when the upgrade handshake never completes.
Diagnosing the Issue
Check Nginx error logs
sudo tail -f /var/log/nginx/error.log
Look for upstream prematurely closed connection entries timed around your WebSocket connection attempts.
Confirm headers are missing upstream
If your backend has access logging, check whether incoming WebSocket requests contain the Upgrade header. You can also simulate the handshake with curl โ hit your backend directly, then hit it through Nginx:
# Direct to backend (should succeed)
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
http://127.0.0.1:3000/ws
# Through Nginx (fails before the fix)
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://example.com/ws
Direct call returns 101 Switching Protocols. Nginx-proxied call returns 400. That gap confirms the missing headers are the cause.
The Fix
Add Upgrade and Connection headers to your proxy block
Open your Nginx server configuration:
sudo nano /etc/nginx/sites-available/example.com
# or
sudo nano /etc/nginx/conf.d/example.com.conf
Find the location block that handles your WebSocket endpoint and add the two required headers:
server {
listen 80;
server_name example.com;
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# These two lines are the fix
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;
}
}
Two details worth noting:
proxy_http_version 1.1is non-negotiable. WebSocket upgrades require HTTP/1.1. Nginx defaults to HTTP/1.0 for proxying, which doesn't support the upgrade mechanism at all.proxy_set_header Upgrade $http_upgradepasses whatever the client sent. For non-WebSocket requests,$http_upgradeis empty โ Nginx skips the header automatically, so this won't break your regular HTTP traffic.
When WebSocket and regular HTTP share the same path
Suppose your API lives at /api and your WebSocket endpoint at /ws, both served from the same backend on port 3000. A single location block needs to set Connection differently depending on the request type. The map directive handles this cleanly:
# In the http {} block (nginx.conf or a conf.d snippet)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.com;
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;
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 requests get Connection: upgrade. Normal HTTP requests get Connection: close. Each gets exactly what it needs.
Tune timeouts for long-lived connections
WebSocket connections stay open for minutes or hours. Nginx's default proxy_read_timeout is 60 seconds โ it will silently kill idle WebSocket sessions without any error in the browser. Add these to your location block:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
3600 seconds (1 hour) is a reasonable starting point. Adjust based on your application's idle patterns.
Apply the configuration
# Test for syntax errors
sudo nginx -t
# Reload without dropping active connections
sudo systemctl reload nginx
Verify the Fix
Check the upgrade handshake succeeds
curl -i -N -H "Upgrade: websocket" -H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
https://example.com/ws
A successful upgrade starts with:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Run an end-to-end test with wscat
npm install -g wscat
wscat -c wss://example.com/ws
If the connection opens and you can exchange messages interactively, you're done.
Watch the error log go quiet
sudo tail -f /var/log/nginx/error.log
Once the fix is live, upstream prematurely closed connection while reading response header from upstream stops appearing when WebSocket clients connect.
Lessons Learned
- Three things must all be present:
proxy_http_version 1.1,Upgradeheader, andConnectionheader. Drop any one of them and the handshake breaks. - The
map $http_upgrade $connection_upgradepattern is the go-to solution when a single location block serves both HTTP and WebSocket traffic. - Nginx's 60-second default read timeout is a silent killer for idle WebSocket sessions. Set
proxy_read_timeoutexplicitly for any WebSocket location block. - Always run
nginx -tbefore reloading. A syntax error on a production server takes down every site on that machine, not just the one you're editing.

