Back to blog
Linux & DevOps October 2, 2026

How to Configure Nginx Reverse Proxy for Secure WebSockets (WSS)

Automate WhatsApp Alerts
Start Free ➔

Secure WebSockets (wss://) are not simply HTTPS requests with longer timeouts. A production WebSocket connection represents a stateful, bidirectional TCP tunnel that transitions from an HTTP/1.1 opening handshake into persistent framing.

Because Upgrade and Connection are HTTP hop-by-hop headers, standard reverse proxies strip them by default. Without explicit Nginx configuration, incoming WebSocket handshakes fail with 400 Bad Request or fall back to CPU-intensive HTTP long-polling.

# Verify Nginx configuration syntax and upstream connection support
sudo nginx -t
sudo nginx -T | grep -E "Upgrade|connection_upgrade|proxy_read_timeout"

30-Second Triage: WebSocket Failure Modes & Quick Fixes

Symptom / Log SignatureRoot CauseExact Nginx Directive / Action
400 Bad Request on HandshakeMissing Upgrade/Connection headers or wrong upstream locationAdd proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade;
Close Code 1006 Abnormal ClosureIdle TCP/TLS socket timeout, upstream crash, or edge RSTEnable application-level ping/pong heartbeats; align proxy_read_timeout above heartbeat interval
Drops Exactly at 60 SecondsNginx proxy_read_timeout defaults to 60s of upstream silenceIncrease proxy_read_timeout 3600s; and implement 25s client-side keepalives
TLS Handshake Failure on wss://Incomplete SSL certificate chain, SNI mismatch, or ALPN mismatchInstall full certificate bundle (fullchain.pem), verify TLS 1.2/1.3 with OpenSSL
Socket.IO Polling Fallback LoopWebSocket upgrade blocked by proxy or CORS policyVerify /socket.io/ location block explicitly passes upgrade headers and handles query parameters
Session ID unknown / 400s Across PodsSocket.IO HTTP polling distributed across multi-node clusterConfigure ip_hash; or cookie-based sticky sessions, backed by Redis Pub/Sub adapter
Handshake Returns 200 OK (Not 101)Request matched standard HTTP route instead of WebSocket blockEnsure location pattern isolates WebSocket traffic and forwards Upgrade: websocket
Too many open files (EMFILE)File Descriptor (FD) exhaustion under 50k+ connectionsRaise worker_rlimit_nofile 200000; and systemd LimitNOFILE

1. Protocol Architecture & The Hop-by-Hop Handshake

A secure WebSocket connection undergoes a strict multi-phase lifecycle before bi-directional message streaming begins:

Client (Browser / Native App)
       │
       ▼ [ 1. TCP Handshake + TLS 1.3 Negotiation ]
┌────────────────────────────────────────────────────────┐
│ Edge Nginx (SSL Termination & Protocol Gateway)        │
│ - Validates SNI, Terminates TLS                        │
│ - Inspects "Upgrade: websocket" & "Connection: Upgrade"│
│ - Evaluates map $http_upgrade $connection_upgrade      │
└──────────────┬─────────────────────────────────────────┘
               │
               ▼ [ 2. HTTP/1.1 Upgrade Request Forwarded ]
┌────────────────────────────────────────────────────────┐
│ Upstream Real-Time Backend Pool                        │
│ (Node.js Socket.IO / Go Gorilla / Python FastAPI)      │
│ - Generates Sec-WebSocket-Accept                       │
│ - Emits "HTTP/1.1 101 Switching Protocols"            │
└──────────────┬─────────────────────────────────────────┘
               │
               ▼ [ 3. 101 Switching Protocols Response ]
┌────────────────────────────────────────────────────────┐
│ Bidirectional TCP Tunnel Established                   │
│ - Nginx ceases HTTP request parsing                    │
│ - Proxies raw WebSocket binary / text frames           │
│ - Monitored by proxy_read_timeout and ping/pong        │
└────────────────────────────────────────────────────────┘

Why Hop-by-Hop Headers Require Explicit Mapping

Per RFC 2616 and RFC 7230, HTTP hop-by-hop headers (Connection, Keep-Alive, Proxy-Authenticate, Upgrade) apply to a single transport link and must not be forwarded by proxies.

When Nginx proxies a request, it automatically converts hop-by-hop headers to Connection: close. To establish a WebSocket tunnel, Nginx must be explicitly instructed to reconstruct the hop-by-hop contract between itself and the upstream service.


2. The Canonical map $http_upgrade Directive

Place the map block inside the http {} context (typically /etc/nginx/nginx.conf):

# /etc/nginx/nginx.conf
http {
    # Dynamically map the Connection header based on client Upgrade request
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }

    include /etc/nginx/conf.d/*.conf;
}

Why Naive Connection "upgrade" Breaks Standard HTTP

A common configuration mistake is hard-coding proxy_set_header Connection "upgrade"; globally or in a mixed API location:

# BROKEN ANTI-PATTERN: Forces Upgrade header on standard REST requests
location / {
    proxy_pass http://backend;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade"; # Breaks ordinary HTTP/1.1 requests!
}

When an ordinary client issues a GET /api/users, it sends no Upgrade header. If Nginx forwards Connection: upgrade with an empty Upgrade header, standards-compliant upstreams (like Node.js, Go net/http, or Python Uvicorn) reject the malformed protocol switch with 400 Bad Request.

The map block guarantees that:

  • If $http_upgrade is present $\to$ $connection_upgrade evaluates to upgrade.
  • If $http_upgrade is empty $\to$ $connection_upgrade evaluates to close.

3. Production-Hardened Nginx WSS Configuration

Below is the complete, production-ready virtual host configuration featuring TLS 1.3, dedicated real-time socket paths, connection pooling, and optimized buffer settings:

# /etc/nginx/conf.d/websocket.conf

upstream realtime_cluster {
    # Load balance across application pods
    least_conn;
    server 10.0.10.11:3000 max_fails=3 fail_timeout=10s;
    server 10.0.10.12:3000 max_fails=3 fail_timeout=10s;
    server 10.0.10.13:3000 max_fails=3 fail_timeout=10s;
    
    # Maintain idle keepalives for standard HTTP endpoints
    keepalive 64;
}

# Redirect HTTP to Secure WebSockets / HTTPS
server {
    listen 80;
    listen [::]:80;
    server_name ws.example.com;
    return 301 https://$host$request_uri;
}

# WSS Edge Ingress
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name ws.example.com;

    # SSL / TLS Hardening
    ssl_certificate /etc/nginx/tls/fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL_WS:50m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # Dedicated WebSocket Location Block
    location /ws/ {
        proxy_pass http://realtime_cluster;
        
        # Mandatory WebSocket Protocol Upgrade Headers
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Client Identity Preservation
        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;

        # Timeout & Keepalive Management
        proxy_connect_timeout 10s;
        proxy_send_timeout 3600s;
        proxy_read_timeout 3600s;

        # Disable buffering for real-time low-latency frame streaming
        proxy_buffering off;
    }

    # Standard REST / Health Check Fallback
    location / {
        proxy_pass http://realtime_cluster;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        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;
    }
}

Verify your SSL certificate deployment and TLS cipher suites with the Pingzo SSL Inspector.


4. Socket.IO & Multi-Node Cluster Considerations

Socket.IO is not raw WebSocket. By default, Socket.IO begins with HTTP long-polling before attempting an in-place protocol upgrade to WebSocket.

The Multi-Node Sticky Session Trap

In a multi-pod cluster, if an initial polling handshake creates a Session ID (sid) on Pod A, but the subsequent polling or upgrade request hits Pod B, Pod B rejects the request with 400 Bad Request: Session ID unknown.

Browser ──► Polling Request 1 (Creates SID=abc) ──► Pod A (State saved)
Browser ──► Polling Request 2 (Sends SID=abc)   ──► Pod B (Unknown SID! -> 400 Error)
# PRODUCTION FIX: Enable IP-Hash or Sticky Session Routing for Socket.IO
upstream socketio_cluster {
    ip_hash; # Routes the same client IP to the same pod
    server 10.0.10.11:3000;
    server 10.0.10.12:3000;
    server 10.0.10.13:3000;
}

server {
    listen 443 ssl;
    server_name realtime.example.com;

    location /socket.io/ {
        proxy_pass http://socketio_cluster;
        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-Forwarded-For $proxy_add_x_forwarded_for;
        
        proxy_read_timeout 90s;
        proxy_send_timeout 90s;
        proxy_buffering off;
    }
}

[!IMPORTANT] ip_hash solves the HTTP polling handshake affinity, but it does not broadcast real-time messages across pods. You must combine Nginx sticky routing with a Redis Pub/Sub Adapter (@socket.io/redis-adapter) inside your Node.js application.


5. Connection Timeouts vs. Heartbeat Architecture

One of the most frequent support tickets in WebSocket infrastructure is: "Why do client connections terminate every 60 seconds?"

Why the 60-Second Drop Occurs

Nginx's proxy_read_timeout defaults to 60s. This setting measures the maximum allowable interval of silence between successive upstream read operations, not the total connection duration. If neither the client nor the server transmits a frame for 60 seconds, Nginx unilaterally closes the socket with TCP FIN.

The 24-Hour Timeout Anti-Pattern

Setting proxy_read_timeout 86400s; (24 hours) without application heartbeats is dangerous:

  • When mobile clients switch cell towers, enter tunnels, or sleep, TCP sockets disconnect silently without sending FIN/RST packets.
  • Without heartbeats, Nginx and upstream backends keep these zombie half-open sockets in memory for 24 hours.
  • At scale, dead sockets exhaust file descriptors and kernel connection tables.

The Correct Production Heartbeat Pattern

Implement active application-level ping/pong frames every 25–30 seconds, and configure proxy_read_timeout to be slightly higher than the heartbeat interval:

$$\text{proxy_read_timeout} = (\text{Heartbeat Interval} \times 2) + 10\text{s}$$

// Node.js WebSocket (ws) Server Heartbeat Implementation
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 3000 });

function heartbeat() {
  this.isAlive = true;
}

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', heartbeat);
});

// Ping clients every 25 seconds
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) return ws.terminate(); // Terminate dead socket
    ws.isAlive = false;
    ws.ping();
  });
}, 25000);

Set Nginx proxy_read_timeout 90s; to allow 2 missed pings before dropping the socket.


6. Linux Kernel & Socket Capacity for 100,000+ Concurrent WebSockets

Unlike short-lived REST queries, 100,000 active WebSockets require 100,000 client sockets plus 100,000 upstream sockets held in memory simultaneously.

File Descriptor Sizing (The $2\times$ Rule)

Every proxied WebSocket consumes two file descriptors on the Nginx host (Client $\leftrightarrow$ Nginx and Nginx $\leftrightarrow$ Upstream).

100,000 Concurrent WebSockets = 200,000 Open Sockets + System Overhead ≈ 250,000 FDs

Configure Nginx and systemd limits:

# /etc/nginx/nginx.conf
worker_processes auto;
worker_rlimit_nofile 500000;

events {
    worker_connections 100000;
    multi_accept on;
    use epoll;
}
# /etc/systemd/system/nginx.service.d/override.conf
[Service]
LimitNOFILE=500000

Apply kernel socket buffer and backlog parameters:

# /etc/sysctl.d/99-websockets.conf
# System-wide file descriptor ceiling
fs.file-max = 2097152

# Socket listen backlog for connection bursts
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535

# Ephemeral port range for upstream connections
net.ipv4.ip_local_port_range = 10240 65535

# TCP memory tuning for high concurrency
net.ipv4.tcp_rmem = 4096 87380 4194304
net.ipv4.tcp_wmem = 4096 65536 4194304
sudo sysctl --system
sudo systemctl daemon-reload
sudo systemctl restart nginx

7. Live CLI Diagnostics & Debugging Runbook

Step 1: Validate TLS Handshake and ALPN

openssl s_client -connect ws.example.com:443 -servername ws.example.com -alpn http/1.1

Look for Verify return code: 0 (ok) and ALPN protocol: http/1.1.

Step 2: Test WebSocket Opening Handshake with curl

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

Expected response:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Step 3: Interactive Testing with wscat

# Install wscat CLI
npm install -g wscat

# Connect and stream frames interactively
wscat -c wss://ws.example.com/ws/

Step 4: Monitor Real-Time Nginx Upgrade Logs

Configure a dedicated WebSocket log format:

log_format ws_log '$remote_addr [$time_local] "$request" '
                  'status=$status bytes=$body_bytes_sent '
                  'upstream=$upstream_addr up_status=$upstream_status '
                  'upgrade="$http_upgrade" conn="$http_connection" '
                  'req_time=$request_time';

access_log /var/log/nginx/ws_access.log ws_log;
tail -f /var/log/nginx/ws_access.log

Test status codes and header propagation using the Pingzo HTTP Status Code Checker and HTTP Header Checker.


8. Synthetic Monitoring & Real-Time SLOs

Standard HTTP GET /health checks verify only that the web server process is alive—they do not detect broken WebSocket upgrade paths or backend event loop lockups.

  1. Synthetic Handshake Probing: Deploy automated probes via Pingzo Uptime Monitoring that execute full TLS negotiation, send Sec-WebSocket-Key headers, and validate the 101 Switching Protocols response.
  2. Ping/Pong Round-Trip Time (RTT): Measure the elapsed time between transmitting a synthetic ping frame and receiving the pong frame across edge regions. Alert when p99 RTT exceeds 500ms.
  3. Abnormal Closure (1006) Spike Alerts: Track unexpected TCP resets and proxy terminations. Trigger P1 on-call alerts if the abnormal closure rate exceeds 1% of active sessions.

Test global server latency with the free Pingzo Ping Test.


Frequently Asked Questions

Why do WebSockets drop precisely after 60 seconds?

Nginx defaults proxy_read_timeout to 60s. If your upstream application does not transmit data or WebSocket ping frames within 60 seconds of silence, Nginx terminates the connection. Either increase proxy_read_timeout or implement 25-second application-level ping/pong heartbeats.

Can I proxy WebSockets over HTTP/2 in Nginx?

Classic WebSockets use the HTTP/1.1 Upgrade: websocket mechanism. RFC 8441 defines WebSockets over HTTP/2 using the Extended CONNECT method (:protocol = websocket), which requires specific upstream server support. For standard open-source Nginx reverse proxying, configure your WebSocket virtual host to negotiate HTTP/1.1 over TLS.

Does Cloudflare require special configuration for WebSockets?

Cloudflare supports WebSockets out-of-the-box on all plan tiers. Ensure the WebSockets toggle is enabled under Network settings in your Cloudflare dashboard. Note that Cloudflare imposes an idle client timeout of 100 seconds, making regular 25-second ping/pong heartbeats essential.

What causes WebSocket Error 1006?

Close code 1006 is a reserved client-side code indicating an abnormal closure where the TCP connection dropped without receiving a clean WebSocket closing handshake frame. Common culprits include intermediate firewall timeouts, Nginx proxy_read_timeout expiration, or upstream backend OOM crashes.

Zero-Code Uptime Alerts

Stop Finding Out About Outages from Angry Users

Get instant WhatsApp & Discord alerts the second your API, website, or server goes down. Setup in 30 seconds with 60-second checks.

WhatsApp & Discord 60-Second Checks Free Forever Plan
Try Pingzo Free

Know before your users do

Connect official WhatsApp notification channels, Discord webhooks, Telegram bots, and public status pages. Start in 30 seconds.

Create Free Monitor