Fixing Nginx 400 Bad Request on WebSocket Reverse Proxy: Missing Upgrade and Connection Headers

intermediateโšก Nginx2026-05-20| Nginx 1.14+ on Ubuntu 20.04/22.04, Debian 11/12, CentOS 7/8, RHEL 8/9 โ€” proxying Node.js, Socket.io, or any WebSocket backend

Error Message

upstream prematurely closed connection while reading response header from upstream
#nginx#websocket#reverse-proxy#upgrade#400

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 protocols
  • Connection: Upgrade โ€” signals that the Upgrade header 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.1 is 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_upgrade passes whatever the client sent. For non-WebSocket requests, $http_upgrade is 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, Upgrade header, and Connection header. Drop any one of them and the handshake breaks.
  • The map $http_upgrade $connection_upgrade pattern 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_timeout explicitly for any WebSocket location block.
  • Always run nginx -t before reloading. A syntax error on a production server takes down every site on that machine, not just the one you're editing.

Related Error Notes