Terminating WSS with nginx and Let’s Encrypt #

ws:// works in development and is blocked in production, because a page served over HTTPS may not open an insecure WebSocket — browsers treat it as mixed content and refuse. So you need wss://, which means TLS, which for most teams means nginx in front of the Node process and a certificate from Let’s Encrypt.

The setup is short. The parts that go wrong are specific: the upgrade map, the renewal reload that severs live connections, and a timeout that kills every socket at sixty seconds.

Root cause #

TLS termination for WebSockets is not conceptually different from TLS for HTTP — the handshake happens before any WebSocket framing, so nginx encrypts the whole tunnel and forwards plaintext upstream. What differs is everything nginx does after the response:

nginx defaults to HTTP/1.0 upstream, and the upgrade mechanism requires HTTP/1.1. Without proxy_http_version 1.1, the upgrade headers are meaningless and the backend answers as ordinary HTTP.

Connection is a hop-by-hop header. nginx strips it by default, exactly as the HTTP specification says a proxy should. You must re-add it explicitly, and it must be set to upgrade only for requests that actually are upgrades — which is what the map block below is for.

proxy_read_timeout defaults to 60 seconds. For request/response that is generous; for a WebSocket it means every idle connection dies at sixty seconds, which users experience as a reconnect loop with no obvious cause.

Certificate renewal reloads nginx. A reload starts new worker processes and asks old ones to shut down gracefully — but “gracefully” for a WebSocket means waiting for a connection that may never close on its own.

What terminates where The browser connects over wss, nginx terminates TLS with the Let's Encrypt certificate, performs the HTTP upgrade with version 1.1, and forwards a plaintext WebSocket to the Node process on loopback. What terminates where Browser wss:// — refuses ws:// from an HTTPS page client TLS handshake nginx presents the Let's Encrypt certificate nginx HTTP upgrade proxy_http_version 1.1 + Upgrade and Connection headers nginx Plaintext tunnel ws:// upstream on the loopback interface internal Node ws server sees X-Forwarded-Proto https, never real TLS app The application never sees TLS, so every origin and security decision depends on the forwarded headers being correct
Encryption ends at nginx. Everything upstream of that line is plaintext on loopback — and must stay on loopback.

Resolution #

The map block is the idiomatic nginx pattern: it sets $connection_upgrade to upgrade for requests carrying an Upgrade header and to close otherwise, so the same server block serves both normal HTTP and WebSockets correctly.

# Must be in the http{} context, not inside server{}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close; # a plain request must NOT be told to upgrade
}

server {
listen 443 ssl;
http2 on;
server_name api.example.com;

# Certbot writes these; the chain file is what older clients need.
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off; # let modern clients choose
ssl_session_cache shared:SSL:20m; # cheaper resumption on reconnect
ssl_session_timeout 1d;
ssl_session_tickets off; # forward secrecy over resumption
ssl_stapling on;
ssl_stapling_verify on;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

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

proxy_http_version 1.1; # REQUIRED: 1.0 cannot upgrade
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; # the app's only view of TLS

# A WebSocket is idle by design. These must exceed your heartbeat interval,
# or nginx kills healthy connections before the application notices.
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;

proxy_buffering off; # do not hold frames in a buffer
}
}

server {
listen 80;
server_name api.example.com;
# Keep the ACME path on HTTP so renewal works without a chicken-and-egg problem.
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}

Issue and renew with certbot’s webroot plugin, which never needs to stop nginx:

# First issue — webroot avoids taking the listener down
certbot certonly --webroot -w /var/www/certbot -d api.example.com \
--non-interactive --agree-tos -m ops@example.com

# Renewal: reload rather than restart, so established tunnels survive the swap
certbot renew --deploy-hook "nginx -s reload"

nginx -s reload is the important half. A reload starts new workers with the new certificate and lets old workers finish serving their existing connections, so live WebSockets are not severed. A restart kills them all at once — every user reconnects simultaneously, which is a self-inflicted reconnect storm every sixty days.

One caveat: old workers linger until their connections close, and a WebSocket may not close for hours. Set worker_shutdown_timeout to a value you are comfortable with — a few hours is reasonable — so that stale workers cannot accumulate indefinitely across renewals.

Certificate renewal without dropping sockets A Let's Encrypt certificate is valid for ninety days and certbot renews at sixty, leaving a thirty-day margin; the deploy hook reloads nginx so existing WebSocket tunnels continue on the old workers. Certificate renewal without dropping sockets 90-day validity 30-day safety margin certificate issued (0day) certbot renews (60day) nginx -s reload (60day) old workers still serving (62day) certificate would expire (90day) Renewal at day 60 leaves a full month of margin — a failed renewal is a warning, not an outage
Reload, never restart: the old workers keep their tunnels alive while new ones pick up the new certificate.

Verification #

Check the certificate, the upgrade, and the idle behaviour separately — they fail independently.

# Certificate chain and expiry as the client sees it
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -subject -dates -issuer

# Does the upgrade survive TLS termination? Expect 101.
curl -i -N --http1.1 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
https://api.example.com/ws

# Idle survival: open a connection, send nothing, confirm it lives past 60s
npx wscat -c wss://api.example.com/ws --no-color

The idle test is the one that catches the timeout default, and it is the failure most often misdiagnosed as an application bug. Open the connection, wait three minutes without sending anything, and confirm it is still alive. If it dies at almost exactly sixty seconds, proxy_read_timeout is still at its default — usually because the location block that actually matched is not the one you edited.

Then confirm the application sees the right protocol: X-Forwarded-Proto must be https, because origin checks and cookie Secure decisions depend on it, and an app that thinks it is serving plaintext will make the wrong call.

The four settings that break wss:// behind nginx Four nginx directives with defaults that break WebSocket proxying, the values they need, and the symptom each produces when left at its default. The four settings that break wss:// behind nginx Default Required Symptom if wrong proxy_http_version 1.0 1.1 200 instead of 101 Upgrade header not forwarded $http_upgrade no upgrade attempted Connection header stripped (hop-by-hop) $connection_upgrade 400 from the backend proxy_read_timeout 60s > heartbeat drops at 60s idle All four are defaults rather than mistakes — nginx is behaving correctly for the traffic it was configured for
Every one of these is a default doing its job for ordinary HTTP. WebSockets need all four changed.

Operational checklist #

FAQ #

Should I terminate TLS at nginx or in Node? #

At nginx, in almost every case. It gives you OCSP stapling, session caching, modern cipher configuration and certificate renewal without touching the application, and it keeps TLS work off the Node event loop where it competes with your message handling. Terminating in Node makes sense only when you need the application to see client certificates or when there is no proxy in the deployment at all.

Does proxy_buffering off matter for WebSockets? #

For the tunnel itself, no — once upgraded, nginx switches to a bidirectional relay and buffering settings no longer apply. It matters for the handshake response and for any streaming HTTP on the same location, and turning it off avoids a class of confusing latency where a small response sits in a buffer. Leaving it off in a WebSocket location is harmless and one less thing to reason about.

Why does my connection still drop at 60 seconds? #

Because something else in the chain has a 60-second idle timeout — a cloud load balancer in front of nginx, a corporate proxy on the client’s network, or a different location block matching the request than the one you configured. Confirm which block matched by adding a distinctive header, and remember that a heartbeat shorter than every timeout in the path is the robust fix rather than raising each one, as covered in connection lifecycle and heartbeats.

Do I need a wildcard certificate? #

Only if you serve WebSockets from multiple subdomains. Let’s Encrypt issues wildcards, but they require DNS-01 validation, which means giving certbot API access to your DNS provider — more moving parts and a larger blast radius if the credential leaks. A separate certificate per hostname with HTTP-01 validation is simpler and is what most deployments should use.

Is HTTP/2 relevant to WebSockets here? #

Only for the pages nginx also serves. The classic WebSocket upgrade is HTTP/1.1-only; browsers open WebSockets over HTTP/1.1 even when the site is served over h2. Enabling http2 on for the same server block is fine and benefits your normal requests — just do not expect it to change anything about the socket, and see HTTP/2 server push vs WebSocket for why the two are not alternatives.

Back to Security & TLS Configuration