Using WebSockets with the Traefik ingress controller

Two days chasing HTTP 426 errors so you don’t have to. Here’s exactly how to get WebSockets running behind Traefik on Civo Kubernetes, and keep them open.

4 minutes reading time

Written by

Jubril Oyetunji
Jubril Oyetunji

Technical Writer at Civo

This tutorial has been updated in 2026. It used to cover the use of the NGINX Kubernetes Ingress Controller, but now uses the Traefik Ingress Controller.

I spent more than two days trying to get WebSockets working behind an ingress controller on Civo Kubernetes.

Every attempt ended the same way, with the browser reporting HTTP error 426, "Upgrade Required", and the connection refusing to upgrade through the load balancer. Stack Overflow threads and blog posts pointed in a dozen directions, and none of them fixed it. This guide is the write-up I wish I had found on day one.

Why WebSockets need help from your ingress

A WebSocket connection begins as a normal HTTP/1.1 GET that carries two extra headers, Connection: Upgrade and Upgrade: websocket, plus a handshake key. The server answers 101 Switching Protocols, and from that moment the same TCP connection stops being HTTP and becomes a two-way message stream that stays open for as long as both sides want it.

Every proxy between the browser and your pod has to cooperate with that handshake. A proxy that strips or rewrites the Connection and Upgrade headers turns the request into a plain GET, the application refuses it, and you see the 426. 

A proxy that handles the handshake but treats the open socket like an idle HTTP keep-alive connection will close it after its idle timeout, which shows up as clients dropping every few minutes for no visible reason.

Traefik handles the first part on its own: it detects the upgrade request, forwards the headers, and hands the connection over to the backend. The second part is governed by its entrypoint timeouts, which we will test rather than assume.

Prerequisites

  • A Civo account
  • The Civo CLI installed and authenticated (civo apikey save <name> <key>)
  • kubectl and helm installed and on your PATH
  • Node.js, so you can run npx wscat to talk to the socket from a terminal

Launching a cluster using the Civo CLI

civo k3s create --create-firewall --nodes 2 --size g4s.kube.medium \
--save --switch --wait ws-demo
kubectl get nodes
NAME STATUS ROLES AGE VERSION
k3s-wslab-b747-3db894-node-pool-59f8-nq134 Ready <none> 2m52s v1.35.0+k3s1
k3s-wslab-b747-3db894-node-pool-59f8-pymou Ready <none> 3m29s v1.35.0+k3s1

Installing Traefik

helm repo add traefik https://traefik.github.io/charts
helm repo update
helm install traefik traefik/traefik \
--namespace traefik --create-namespace \
--set service.type=LoadBalancer \
--wait

Civo provisions a Load Balancer and attaches a public IP to the Traefik Service:

kubectl -n traefik get svc traefik
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
traefik LoadBalancer 10.43.135.145 212.2.245.247 80:31937/TCP,443:32112/TCP 2m15s

Deploying the WebSocket application

The application is the same one the original guide used: a small Tornado server that serves a test page and a WebSocket endpoint on port 8000. Deploy it with a single replica and a ClusterIP Service in front of it:

kubectl apply -f - <<'EOF2'
apiVersion: apps/v1
kind: Deployment
metadata:
name: tornado-socket
labels:
app: tornado-socket
group: testing
spec:
replicas: 1
selector:
matchLabels:
app: tornado-socket
template:
metadata:
labels:
app: tornado-socket
group: testing
spec:
containers:
- name: tornado-socket
image: ajayinnvonix/tornado-demo
ports:
- name: websocket
containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: tornado-socket
labels:
app: tornado-socket
group: testing
spec:
type: ClusterIP
selector:
app: tornado-socket
ports:
- name: websocket
protocol: TCP
port: 8000
targetPort: 8000
EOF2
kubectl get pods -l app=tornado-socket

Traefik answering on a Civo Load Balancer IP, and the WebSocket application ready behind it.

Exposing the socket through Traefik

With Traefik. we'll use a nip.io hostname built from the Load Balancer IP so you don't need a domain:

LB_IP=$(kubectl -n traefik get svc traefik \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}')
HOST=ws.${LB_IP}.nip.io
echo "Using HOST=$HOST"
kubectl apply -f - <<EOF
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: tornado-socket
spec:
entryPoints:
- web
routes:
- match: Host(\`${HOST}\`)
kind: Rule
services:
- name: tornado-socket
port: 8000
EOF

Per Traefik's documentation, WebSocket needs "no special configuration beyond standard HTTP routing", and the proxy "automatically detects and handles the WebSocket upgrade", preserving Sec-WebSocket-Key, Sec-WebSocket-Version, and Origin on the way through. The rest of this guide checks that claim rather than taking it on trust.

Testing the WebSocket connection

Start with the handshake itself, because that is exactly where the 426 used to appear. Send the upgrade request by hand with curl:

curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://${HOST}/ws
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Date: Tue, 08 Sep 2026 13:52:03 GMT
Sec-Websocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Server: TornadoServer/6.1
Upgrade: websocket

101 Switching Protocols straight from the Tornado server, passed through Traefik untouched. This response is the whole fix.

Now use the socket for what the application was built for. The Tornado demo is a TSV parser: it expects the file as a binary frame, answers with the first page as JSON, and then accepts a page number as a text frame. A short Node script exercises both. Save it as parse.js:

npm init -y >/dev/null && npm install ws@8 >/dev/null
cat > parse.js <<'EOF'
const WebSocket = require('ws');
const ws = new WebSocket(process.argv[2]);
const tsv = 'name\tcity\tlanguage\nAda\tLondon\tAnalytical Engine\nGrace\tArlington\tCOBOL';
ws.on('open', () => {
console.log('connected to', process.argv[2]);
ws.send(Buffer.from(tsv), { binary: true }); // the file, as a binary frame
setTimeout(() => ws.send('1'), 800); // page number, as a text frame
setTimeout(() => ws.close(1000), 2500);
});
ws.on('message', m => console.log('received:', m.toString()));
ws.on('close', c => console.log('closed cleanly, code', c));
EOF
node parse.js ws://${HOST}/ws

The TSV goes up as a binary frame and comes back parsed. The second reply is the same page requested by number.

One detail worth knowing if you poke at this endpoint with a generic client such as wscat: any text frame that is not a number makes the server raise and close the socket with code 1006. 

Open http://${HOST}/ and click Connect:

The page's own WebSocket client, connected through the same route.

Keeping long-lived sockets open

The original guide set NGINX's proxy-read-timeout and proxy-send-timeout to an hour, because NGINX would otherwise cut an idle socket after sixty seconds. It is fair to ask what Traefik does with the same idle socket.

Traefik's entrypoint has three response timeouts:

SettingDefaultWhat it covers

respondingTimeouts.readTimeout

60s

Reading the entire incoming request, including its body

respondingTimeouts.writeTimeout

0s (off)

From the end of the request headers to the end of the response

respondingTimeouts.idleTimeout

180s

How long an idle keep-alive connection is held before it is closed

None of them mention WebSockets, so the best way to find out is to open a socket, send nothing for longer than the longest default, and see whether it is still there. Save this as hold.js:

cat > hold.js <<'EOF'
const WebSocket = require('ws');
const [,, url, idle] = process.argv;
const t0 = Date.now(), ts = () => `[+${((Date.now() - t0) / 1000).toFixed(1)}s]`;
const ws = new WebSocket(url);
ws.on('open', () => console.log(ts(), 'connected, staying silent for', idle, 's'));
ws.on('message', m => console.log(ts(), 'received:', m.toString().slice(0, 80)));
ws.on('close', c => { console.log(ts(), 'CLOSED code', c); process.exit(1); });
setTimeout(() => {
console.log(ts(), 'idle complete, readyState =', ws.readyState === 1 ? 'OPEN' : ws.readyState);
ws.send(Buffer.from('name\tage\nAda\t36'), { binary: true });
setTimeout(() => { console.log(ts(), 'still open, closing cleanly'); ws.close(1000); }, 3000);
}, Number(idle) * 1000);
EOF
node hold.js ws://${HOST}/ws 200

The socket outlives both the 60 second read timeout and the 180 second idle timeout without any configuration.

The reason is that those timeouts apply to HTTP requests and to idle keep-alive connections between requests. Once the 101 is exchanged, Traefik hands the raw connection over to the application, and it is no longer an HTTP connection that the timeouts have a say in. If you ever do need to change them, for example, to shorten idleTimeout on a busy edge, they live on the entrypoint. In the Helm chart, that is:

helm upgrade traefik traefik/traefik --namespace traefik --reuse-values \
--set ports.web.transport.respondingTimeouts.idleTimeout=180s \
--set ports.web.transport.respondingTimeouts.readTimeout=60s

Cleaning up

civo k3s remove ws-demo
Jubril Oyetunji
Jubril Oyetunji

Technical Writer at Civo

Jubril Oyetunji is a DevOps engineer and technical writer with a strong focus on cloud-native technologies and open-source tools. His work centers on creating practical tutorials that help developers better understand platforms such as Kubernetes, NGINX, Rust, and Go.

As a contract technical writer, Jubril authored an extensive library of technical guides covering cloud-native infrastructure and modern development workflows. Many of his tutorials achieved strong search rankings, helping developers around the world learn and adopt emerging technologies.

View author profile