How to Configure Nginx as a WebSocket Reverse Proxy

Written by: Bagus Facsi Aginsa
Published at: 07 Sep 2021


Your WebSocket application works perfectly when clients connect to it directly, but the moment you put Nginx in front of it, connections fail with 426 Upgrade Required, drop after exactly 60 seconds, or never complete the handshake at all. This happens because WebSocket is not plain HTTP: it starts as an HTTP request and then upgrades the connection to a persistent, bidirectional channel. A reverse proxy that does not forward that upgrade correctly breaks the protocol.

In this tutorial you will configure Nginx as a WebSocket reverse proxy: terminate secure WebSocket (wss) on Nginx, forward plain WebSocket (ws) to your upstream application, keep long-lived connections from timing out, and load balance across multiple WebSocket backends.


How WebSocket Proxying Works

A WebSocket connection begins life as a normal HTTP/1.1 request with two special headers:

GET /socket HTTP/1.1
Host: my.websocket.app
Upgrade: websocket
Connection: Upgrade

If the server agrees, it responds with 101 Switching Protocols, and from that point the TCP connection stops being HTTP: both sides can send frames at any time, and the connection stays open for minutes or hours.

This creates two requirements for any reverse proxy in the middle:

  1. The Upgrade and Connection headers are hop-by-hop headers. They apply to a single connection, not the whole request chain, so Nginx does not forward them by default. You must explicitly pass them to the upstream, otherwise the upstream never sees the upgrade request and the handshake fails.
  2. The connection is long-lived and mostly idle. Nginx closes an upstream connection after 60 seconds without data by default. A chat client that has not sent a message in a minute gets disconnected unless you raise the read timeout or the application sends keepalive pings.

WebSocket upgrade requires HTTP/1.1 between Nginx and the upstream, which is why the configuration below pins proxy_http_version 1.1.


Prerequisites

  • A server running Ubuntu 22.04 or 24.04 LTS (any modern Linux works, commands assume Ubuntu)
  • Nginx installed. sudo apt install nginx is enough, no extra modules are needed
  • A WebSocket application to proxy to (any ws server on any port)
  • A user with sudo privileges
  • For the wss setup: an SSL certificate for your domain

The Use Case

To keep the examples concrete, this tutorial uses the following setup:

                        ___________              ____________
              wss      |           |     ws     |            |
     user -----------> |   Nginx   |----------> |    App     |
                       |___________|            |____________|
  • Nginx listens on port 443 at IP 10.1.0.10 and serves the domain my.websocket.app.
  • App is a plain WebSocket (ws) server listening on 10.2.0.20:3000.

Clients connect with wss://my.websocket.app, Nginx terminates the SSL, and forwards the traffic as plain ws to the application. This pattern is called SSL offloading, and it means your application never has to deal with certificates.


Step 1: Create the Server Block

Rather than replacing the whole nginx.conf, create a dedicated config file for the WebSocket proxy:

sudo nano /etc/nginx/conf.d/websocket.conf

Add the following configuration:

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

server {
    listen 443 ssl;
    http2 on;
    server_name my.websocket.app;

    ssl_protocols TLSv1.3 TLSv1.2;
    ssl_certificate /etc/ssl/cert_file.crt;
    ssl_certificate_key /etc/ssl/key_file.key;

    location / {
        proxy_pass http://10.2.0.20: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;

        proxy_read_timeout 300s;
        proxy_send_timeout 300s;
    }
}

Replace the certificate paths with your real certificate and key, and 10.2.0.20:3000 with your application’s address.


Step 2: Understand Each Directive

The map block. This is the piece most old tutorials skip. It sets $connection_upgrade to upgrade when the client sends an Upgrade header, and to close when it does not. The result: WebSocket requests get upgraded, while ordinary HTTP requests to the same location are proxied normally instead of being forced into a bogus upgrade. If your location serves only WebSocket traffic, hardcoding proxy_set_header Connection "Upgrade"; also works, but the map version handles mixed HTTP/WebSocket endpoints correctly, which is what most real applications (Socket.IO, SignalR, Jitsi) need.

proxy_http_version 1.1. The WebSocket handshake is only defined for HTTP/1.1. Nginx talks HTTP/1.0 to upstreams by default, which cannot carry an upgrade. This directive is mandatory.

proxy_set_header Upgrade $http_upgrade. Forwards the client’s Upgrade: websocket header to the upstream. Because Upgrade is hop-by-hop, Nginx drops it unless you pass it explicitly.

proxy_set_header Host $host. Passes the original Host header upstream. Many WebSocket servers validate the Host or Origin during the handshake and reject requests that arrive with the upstream’s IP as the host.

X-Real-IP, X-Forwarded-For, X-Forwarded-Proto. Without these, your application sees every connection as coming from the Nginx server’s IP. These headers preserve the real client IP and the fact that the original connection was encrypted.

proxy_read_timeout 300s. The critical one for connection stability. If Nginx sees no data from the upstream for this long, it closes the connection. The default is 60 seconds, which silently kills any idle WebSocket. Five minutes is a reasonable start; if your application sends ping/pong frames (most do), any value comfortably above the ping interval works.

A note on listen 443 ssl; http2 on;: older configs wrote this as listen 443 ssl http2;. That combined form is deprecated since Nginx 1.25; the separate http2 on; directive is the current syntax. WebSocket connections themselves negotiate HTTP/1.1, browsers handle this automatically.


Step 3: Test and Reload

Verify the configuration syntax:

sudo nginx -t

Expected output:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Reload Nginx to pick up the new configuration without dropping existing connections:

sudo systemctl reload nginx

Step 4: Verify the WebSocket Connection

The quickest way to test a WebSocket endpoint from the command line is wscat:

sudo apt install node-ws
wscat -c wss://my.websocket.app

If everything works you get an interactive prompt:

Connected (press CTRL+C to quit)
>

Anything you type is sent to your application over the proxied WebSocket. If your app echoes messages back, you will see them appear.

You can also verify the handshake itself with curl:

curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
  -H "Sec-WebSocket-Version: 13" \
  https://my.websocket.app/

A working proxy returns:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: upgrade

If you get 200 OK instead of 101, the upgrade headers are not reaching your upstream. If you get 502 Bad Gateway, Nginx cannot reach the upstream at all.


Plain WebSocket (ws) Without SSL

If you do not need SSL termination, for example when the proxy sits on an internal network, drop the SSL directives and listen on port 80:

server {
    listen 80;
    server_name my.websocket.app;

    location / {
        proxy_pass http://10.2.0.20:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $host;
        proxy_read_timeout 300s;
    }
}

Clients then connect with ws:// instead of wss://. For anything reachable from the public internet, use wss. Browsers block plain ws from pages served over HTTPS anyway.


Load Balancing Multiple WebSocket Backends

Because WebSocket connections are stateful and long-lived, load balancing them is different from load balancing HTTP: once a client is connected, it stays on one backend for the life of the connection. The balancing decision only happens at handshake time.

Define an upstream block and point proxy_pass at it:

upstream websocket_backend {
    ip_hash;
    server 10.2.0.20:3000;
    server 10.2.0.21:3000;
    server 10.2.0.22:3000;
}

server {
    ...
    location / {
        proxy_pass http://websocket_backend;
        ...
    }
}

ip_hash makes every connection from the same client IP land on the same backend. This matters when a client reconnects and your application keeps per-client state in the backend’s memory. Without session affinity, a reconnect can land on a server that has never seen that client. If your backends share state through Redis or a message broker, you can remove ip_hash and let Nginx distribute connections round-robin.

For a deeper look at upstream configuration, health checks, and balancing methods, see How to Configure Nginx as Layer 7 Load Balancer.


Common Problems and Troubleshooting

Connection drops after exactly 60 seconds.

This is the proxy_read_timeout default. Either raise it (as in the config above) or make your application send WebSocket ping frames more often than the timeout. If you see disconnects at some other suspiciously round number, check for additional proxies. Cloud load balancers often have their own idle timeouts.

Client receives 426 Upgrade Required or 400 Bad Request.

The upgrade headers are not being forwarded. Confirm all three directives are present in the location block: proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection $connection_upgrade (with the map block defined at http level, outside the server block).

502 Bad Gateway.

Nginx cannot reach the upstream. Test connectivity from the Nginx server itself: curl http://10.2.0.20:3000. Check that the application is listening on the right interface (0.0.0.0, not 127.0.0.1) and that no firewall blocks the port between the two machines.

Handshake works, but the application rejects the connection.

Many frameworks validate the Origin or Host header. Make sure proxy_set_header Host $host is set, and check the application logs for CORS or origin errors. Some frameworks need to be told they are behind a proxy (for example, trust proxy in Express).

It works via IP but not via the domain.

Check server_name matches the domain the client uses, and remember every directive line in Nginx ends with a semicolon. A missing ; after server_name makes nginx -t fail with an error pointing at the next line, which sends you looking in the wrong place.


Best Practices

Always set an explicit proxy_read_timeout. The 60-second default is the single most common cause of “my WebSocket randomly disconnects” reports. Set it above your application’s ping interval.

Use the map block instead of hardcoding Connection "Upgrade". It costs two lines and makes the same location work for both WebSocket and regular HTTP traffic.

Terminate SSL at Nginx with a real certificate. Self-signed certificates cause silent WebSocket failures in browsers, because the certificate warning page never appears for a ws connection. Get a free trusted certificate with Let’s Encrypt: Secure Nginx with Let’s Encrypt SSL Using Certbot on Ubuntu.

Protect the handshake endpoint from abuse. Each WebSocket connection holds a file descriptor open for its lifetime, which makes connection floods cheap for an attacker. Rate limit the handshake location: Configure Nginx Rate Limiting on Ubuntu.

Raise worker connection limits for high connection counts. Every proxied WebSocket consumes two connections (client side and upstream side). If you expect thousands of concurrent clients, raise worker_connections in the events block and worker_rlimit_nofile accordingly.


Conclusion

You have configured Nginx as a WebSocket reverse proxy: the map block plus Upgrade/Connection headers make the handshake work, proxy_http_version 1.1 carries it, sensible timeouts keep idle connections alive, and ip_hash gives you session affinity when balancing across multiple backends.

A real-world application of exactly this pattern is proxying the Jitsi Videobridge data channel. See How to Configure WebSocket in Jitsi Videobridge. And if your proxy also fronts plain HTTP services, Understanding Nginx Location Block explains how Nginx picks which location handles which request.