How to Configure Nginx as a WebSocket Proxy

How to Configure Nginx as a WebSocket Proxy

How to Configure Nginx as a WebSocket Proxy

WebSockets power a lot of what feels “live” on the modern web — chat apps, real-time notifications, collaborative editing, live dashboards, multiplayer games. If you’re running a WebSocket server behind Nginx (which is extremely common, since you usually want Nginx handling TLS termination, load balancing, and routing in front of your actual app), there’s a specific set of configuration details you need to get right, or connections will fail or drop unexpectedly.

I’ll walk through exactly why WebSockets need special handling in a proxy context, how to configure it correctly, and how to debug it when it doesn’t work.

Why WebSockets Need Special Proxy Configuration

A WebSocket connection starts life as a regular HTTP request. The client sends an HTTP GET request with specific headers — Upgrade: websocket and Connection: Upgrade — asking the server to switch protocols. If the server agrees, it responds with 101 Switching Protocols, and from that point on, the same underlying TCP connection is used for full-duplex WebSocket communication instead of regular HTTP request/response cycles.

By default, Nginx’s proxy_pass doesn’t forward the Upgrade and Connection headers needed for this handshake, and HTTP/1.0 proxying behavior (which Nginx uses by default toward upstreams) doesn’t support the Upgrade mechanism at all. Without explicit configuration, your WebSocket handshake will simply fail, or the connection will be dropped shortly after appearing to work.

Requirements

Check your version:

nginx -v

The Core Configuration

Here’s the minimum required configuration to proxy WebSocket connections correctly:

location /ws/ {
    proxy_pass http://127.0.0.1:3000;

    proxy_http_version 1.1;
    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;
}

Let’s break down why each of these matters:

The Dynamic Connection Header Pattern

If your location block serves both regular HTTP requests and WebSocket upgrade requests (common when a single backend exposes both a REST API and a WebSocket endpoint on overlapping paths), hardcoding Connection: upgrade unconditionally can cause subtle issues with connection reuse for normal HTTP traffic. The standard fix, straight from Nginx’s own documentation, uses a map block:

http {
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    server {
        listen 80;
        server_name example.com;

        location /ws/ {
            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;
        }
    }
}

This way, if the client sends an Upgrade header, Nginx forwards Connection: upgrade. If it’s a normal HTTP request with no Upgrade header ($http_upgrade is empty), Nginx sends Connection: close instead, which is safer default behavior for regular proxied requests.

Handling Long-Lived Connections: Timeouts

WebSocket connections are often long-lived — sometimes staying open for hours. Nginx’s default proxy timeouts are much shorter than that, and it will silently close idle connections that exceed them, which looks like a random disconnect to your users.

location /ws/ {
    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_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_connect_timeout 60s;
}

An hour (3600s) is a common starting point; adjust based on your application’s actual idle behavior. Some people set this much higher (or effectively unlimited) for persistent connections, relying on application-level heartbeats/pings instead to detect and clean up genuinely dead connections.

Application-Level Heartbeats

Even with generous Nginx timeouts, I strongly recommend implementing ping/pong heartbeats at the WebSocket application layer (most WebSocket libraries support this natively). This keeps the connection demonstrably alive, gives you a clean way to detect and clean up genuinely dead connections (like a client that lost network without a proper close handshake), and works around any intermediate timeout — including ones you don’t control, like corporate proxies or mobile carrier NAT tables between the client and your server.

Load Balancing WebSocket Connections

If you’re running multiple backend WebSocket servers behind Nginx, you generally need sticky sessions (also called session affinity), since a WebSocket connection stays pinned to whichever backend instance accepted the original handshake — you can’t transparently move an established WebSocket connection to a different backend mid-session the way you might with stateless HTTP requests.

upstream websocket_backend {
    ip_hash;
    server 10.0.0.10:3000;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
}

server {
    listen 80;
    server_name example.com;

    location /ws/ {
        proxy_pass http://websocket_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 3600s;
    }
}

ip_hash routes a given client IP consistently to the same backend, which works reasonably well for sticky sessions in simple deployments. For more robust session affinity — especially behind NAT where many clients share one IP — consider a solution that uses cookies or, if you’re running a message broker (like Redis pub/sub) shared across backend instances, you may not need sticky sessions at all, since any backend can handle any client and broadcast messages through the shared broker.

Complete Example Configuration

Here’s a full setup combining TLS termination, dynamic connection header handling, and reasonable timeouts — a realistic production configuration for something like a chat application:

http {
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    upstream chat_backend {
        ip_hash;
        server 10.0.0.10:3000;
        server 10.0.0.11:3000;
    }

    server {
        listen 80;
        server_name chat.example.com;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name chat.example.com;

        ssl_certificate     /etc/letsencrypt/live/chat.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;

        location /socket.io/ {
            proxy_pass http://chat_backend;
            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;

            proxy_read_timeout 3600s;
            proxy_send_timeout 3600s;
        }

        location / {
            root /var/www/chat.example.com;
            try_files $uri $uri/ /index.html;
        }
    }
}

Testing Your Configuration

Start with basic syntax validation:

sudo nginx -t
sudo systemctl reload nginx

You can test a WebSocket handshake directly with curl, which supports sending the upgrade headers manually:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  http://example.com/ws/

A successful proxy configuration returns HTTP/1.1 101 Switching Protocols. If you instead get a 200, 400, or 502, something’s wrong with either the upgrade headers or the connection to your backend.

For more thorough testing, browser DevTools are genuinely the best tool. Open the Network tab, filter by “WS,” and connect to your WebSocket endpoint from your actual frontend. You’ll see the handshake request/response, and you can inspect individual frames being sent and received in real time.

Command-line WebSocket clients like websocat are also useful for quick manual testing:

websocat wss://chat.example.com/socket.io/

Troubleshooting Common Issues

Handshake fails with a 400 or 426 error. Check that proxy_http_version 1.1; is set — without it, Nginx can’t properly relay the upgrade handshake. Also confirm the Upgrade and Connection headers are actually being forwarded correctly, not just set to static placeholder values that don’t match what the client sent.

Connection establishes but drops after ~60 seconds of inactivity. This is almost always the default proxy_read_timeout or proxy_send_timeout kicking in. Increase both, and consider adding application-level pings as a more robust long-term fix.

Works fine with one backend but breaks when load balancing across multiple. You’re likely missing sticky sessions. Add ip_hash; to your upstream block, or migrate to a shared-state architecture (like Redis pub/sub) that doesn’t require session affinity.

Works over HTTP but fails once TLS/WSS is involved. Make sure your frontend is actually connecting via wss:// (not ws://) when the page itself is served over HTTPS — browsers block insecure WebSocket connections from secure pages (mixed content). Also double check your certificate covers the exact hostname used for the WebSocket endpoint.

Intermittent disconnects that seem random. This is frequently caused by an intermediate layer with its own idle timeout — a corporate firewall, a mobile carrier’s NAT table, or a cloud load balancer sitting in front of Nginx with a shorter timeout than Nginx itself. Application-level heartbeats sent every 20-30 seconds are the most reliable fix here, since they keep the connection demonstrably active at every layer.

Security Considerations

limit_req_zone $binary_remote_addr zone=ws_limit:10m rate=5r/s;

location /ws/ {
    limit_req zone=ws_limit burst=10 nodelay;
    ...
}

Performance Tips

Real-World Use Cases

Best Practices

Getting WebSocket proxying right through Nginx is mostly about a handful of specific directives that are easy to forget if you’re used to configuring plain HTTP reverse proxies. Once they’re in place, though, it’s a solid, battle-tested setup that handles real-time traffic reliably at scale.

Exit mobile version