TURN behind a reverse proxy¶
LiveKit ships a built-in TURN server. It gives clients behind restrictive networks a media path over TLS on port 443, so voice and video can still work when direct WebRTC traffic is blocked.
This page covers the setup that is easy to get wrong: TURN on a host where something else — Nginx Proxy Manager, Traefik, Caddy, a cloud load balancer — already owns port 443.
Do you need this?¶
Voice and video try direct WebRTC first: UDP on port 7882, with TCP on 7881 as a fallback. Most networks allow this. Some do not — corporate and school firewalls, guest Wi-Fi and some mobile carriers allow little more than HTTP(S) on 80 and 443.
Symptoms when a client's network blocks WebRTC:
- a user joins a voice channel and hears nothing, and nobody hears them
- the call connects and then drops, and reconnecting fails
- it works on one network (home, phone hotspot) but not another (office, campus)
TURN relays media over TLS on 443 — traffic a firewall sees as ordinary HTTPS. If only some users are affected, you need TURN. Everyone else keeps using the direct path; TURN is only a fallback.
LiveKit always advertises TURN on port 443
When TURN/TLS is enabled, LiveKit hands every client the address turns:turn.example.com:443?transport=tcp, no matter which port it listens on locally (turn.tls_port). Clients only ever dial 443. That is why TURN conflicts with a reverse proxy that terminates HTTPS on 443 — and why the fix below splits 443 by SNI instead of fighting over it.
How it works¶
Two independent pieces, and both are required:
- SNI routing on 443. A layer-4 router reads the TLS SNI hostname from the ClientHello, before any TLS termination. Connections for
turn.example.comare passed through — still encrypted — to LiveKit's TURN/TLS port (5349by default). Everything else goes to your reverse proxy exactly as before. LiveKit terminates TURN/TLS itself. - A fixed relay port range. Once a client is authenticated over TURN/TLS, TURN relays the actual media over UDP, allocating a port per session. By default those ports are picked from a 10,000-port range (
30000–40000), which no router or load balancer can forward. Pin the range, then forward it.
Skipping either step fails in a specific way: without SNI routing, clients can't reach TURN at all (signaling over 443 hits the reverse proxy and breaks); without the relay range, TURN signaling succeeds but media never flows, so a user whose UDP 7882 path is blocked still gets nothing.
graph LR
Client[Client, firewall<br/>allows only 80/443] -->|"turns:turn.example.com:443"| SNI[L4 SNI router<br/>:443]
SNI -->|"SNI = turn.example.com<br/>(TLS passthrough)"| LK[LiveKit TURN/TLS<br/>:5349]
SNI -->|"any other SNI"| Proxy[Reverse proxy<br/>:8443]
Proxy --> App[Semaphore Chat]
LK -->|"relay UDP 50000-50100<br/>(101 ports, inclusive)"| Client relay_range_end is inclusive
LiveKit allocates relay ports from relay_range_start through relay_range_end inclusive. With 50000/50100 that is 101 ports (50000–50100), and port 50100 is usable. LiveKit hands both bounds to pion/turn's RelayAddressGeneratorPortRange, whose MaxPort is documented as "the maximum (inclusive) port to allocate". Forward every port in the range end to end — a firewall rule that stops at 50099 leaves the last port unreachable, and a TURN session that lands on it stalls.
1. Configure LiveKit¶
Add a turn: section to the LiveKit config. With Docker Compose this is the LIVEKIT_CONFIG environment variable; with a config file it is passed with --config.
port: 7880
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: true
turn:
enabled: true
# Must match the certificate's domain. Clients are told to reach this on 443.
domain: turn.example.com
# LiveKit's own TURN/TLS listener. The SNI router forwards to it; clients never see it.
tls_port: 5349
# The SNI router passes TLS through untouched, so LiveKit terminates it.
external_tls: false
cert_file: /etc/livekit/turn.crt
key_file: /etc/livekit/turn.key
# Pin the relay range so it can be forwarded (the default is 30000-40000).
# Both bounds are inclusive: this is 101 ports, 50000 through 50100.
relay_range_start: 50000
relay_range_end: 50100
Notes on the fields:
| Field | Value | Why |
|---|---|---|
domain | turn.example.com | Advertised to clients and must match the TLS certificate. A wildcard certificate (*.example.com) works. |
tls_port | 5349 | Where LiveKit listens. Clients are still told 443; the SNI router connects here. |
external_tls | false | LiveKit terminates TLS itself, using cert_file/key_file. Set true only if an L4 proxy terminates TURN/TLS for you and forwards plaintext. |
relay_range_start / relay_range_end | 50000 / 50100 | Pin the relay ports. Both bounds are inclusive, so this is 101 ports (50000–50100). A range of ~100 ports is plenty for a small instance; size it to your expected concurrent TURN sessions, and forward every port in it. |
udp_port | unset | Enables TURN over UDP on a second port. Not needed here — restrictive networks need TURN/TLS on 443. If you set it, that UDP port must be reachable too. |
Certificate
cert_file/key_file point at a certificate valid for turn.example.com. A wildcard certificate for your domain is the usual choice; if the TURN hostname is on a different domain, issue one for it. In Docker, mount the files into the container (- ./certs:/etc/livekit:ro).
2. Run an SNI router on port 443¶
The router takes over 443 and your reverse proxy moves to another port (here 8443). The router never decrypts anything: it reads the SNI hostname from the ClientHello and forwards the raw TCP stream.
nginx with ssl_preread¶
The most portable option, available in the official nginx images and most distro packages (from the stream and ngx_stream_ssl_preread_module modules).
stream {
# Read the SNI hostname, pick a backend, forward the encrypted stream.
map $ssl_preread_server_name $sni_upstream {
turn.example.com 127.0.0.1:5349; # LiveKit TURN/TLS (turn.tls_port)
default 127.0.0.1:8443; # your reverse proxy, moved off 443
}
server {
listen 443;
listen [::]:443;
ssl_preread on;
proxy_pass $sni_upstream;
# TURN/TLS connections stay open for the length of a call.
proxy_timeout 1h;
}
}
Then have your reverse proxy (NPM, nginx, Caddy, Traefik…) listen for HTTPS on 8443 instead of 443. Point its turn.example.com DNS name at the same host so the SNI router sees that SNI.
Running the router in Docker
Give the router container the reverse proxy and LiveKit on a shared Docker network and use their service names, resolving them at request time with Docker's embedded DNS:
stream {
resolver 127.0.0.11 valid=10s ipv6=off;
map $ssl_preread_server_name $sni_upstream {
turn.example.com livekit:5349;
default reverse-proxy:8443;
}
server {
listen 443;
ssl_preread on;
proxy_pass $sni_upstream;
proxy_timeout 1h;
}
}
services:
sni-router:
image: nginx:latest
restart: unless-stopped
ports:
- "443:443"
volumes:
- ./sni-router.conf:/etc/nginx/nginx.conf:ro
The reverse proxy must stop publishing 443 (bind it to 8443 internally) so only the router owns it.
Other proxies¶
-
HAProxy — match on
req.ssl_sniin a TCP frontend:frontend tls bind :443 mode tcp tcp-request inspect-delay 5s tcp-request content accept if { req_ssl_hello_type 1 } use_backend livekit_turn if { req_ssl_sni -i turn.example.com } default_backend reverse_proxy backend livekit_turn mode tcp server livekit 127.0.0.1:5349 backend reverse_proxy mode tcp server proxy 127.0.0.1:8443 -
Traefik — a TCP router with
HostSNIand TLS passthrough:tcp: routers: turn: rule: "HostSNI(`turn.example.com`)" entryPoints: [websecure] service: livekit-turn tls: passthrough: true https: rule: "HostSNI(`*`)" entryPoints: [websecure] service: reverse-proxy services: livekit-turn: loadBalancer: servers: - address: "127.0.0.1:5349" reverse-proxy: loadBalancer: servers: - address: "127.0.0.1:8443"The catch-all
HostSNI(\*`)` router must also be a TCP router for the non-TURN traffic to keep working.
3. Open and forward ports¶
| Port | Protocol | Purpose |
|---|---|---|
443 | TCP | The only port clients use for TURN/TLS. The SNI router sends turn.example.com to LiveKit and everything else to the reverse proxy. |
5349 | TCP | LiveKit's TURN/TLS listener (turn.tls_port). Internal: the SNI router and LiveKit must reach each other here. Do not expose it publicly when the router owns 443. |
7881 | TCP | Direct WebRTC over TCP (fallback when UDP is blocked). |
7882 | UDP | Direct WebRTC media. |
50000–50100 | UDP | TURN relay media (turn.relay_range_start–end, both inclusive — 101 ports). Forward from your router/firewall to LiveKit. |
Docker Compose publishes the relay range like any other port:
services:
livekit:
ports:
- "7881:7881" # WebRTC over TCP
- "7882:7882/udp" # WebRTC media (direct)
- "127.0.0.1:5349:5349" # TURN/TLS: localhost only, the SNI router forwards to it
- "50000-50100:50000-50100/udp" # TURN relay media
On a home router or firewall, port-forward UDP 50000–50100 (all 101 ports, inclusive) to the host running LiveKit as well.
Kubernetes¶
On Kubernetes, the relay ports must reach the LiveKit pod through its LoadBalancer Service. A Service cannot declare a port range, so list each UDP port (50000 through 50100, inclusive — 101 entries) in the LiveKit Service — the same Service that carries 7881/7882, or the chart's TURN load-balancer Service.
apiVersion: v1
kind: Service
metadata:
name: livekit-turn-relay
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: livekit-server
ports:
# TURN/TLS. Only needed if the SNI router targets this Service instead of a
# host port; the relay ports below carry the media.
- name: turn-tls
port: 5349
targetPort: 5349
protocol: TCP
# TURN relay range, one entry per port — 50000 through 50100 inclusive
# (101 ports), matching turn.relay_range_start/end.
- name: relay-50000
port: 50000
targetPort: 50000
protocol: UDP
- name: relay-50001
port: 50001
targetPort: 50001
protocol: UDP
# ... continue through 50100
If you deploy LiveKit with its Helm chart, add the range to the Service the chart exposes (the turnLoadbalancer Service fronts only 443 → tls_port by default) and set the same relay_range_start/relay_range_end in the LiveKit config. On a bare-metal cluster with MetalLB, the LoadBalancer IP must accept the whole UDP range; the ports are declared one by one on the Service.
Verify¶
-
LiveKit logs. Check that TURN started with the values you set:
Expected (trimmed):
-
ICE candidates. Open the Trickle ICE test page or icetest.info. Add
turn:turn.example.com:443?transport=tcpwith a TURN username and credential, and gather candidates. LiveKit issues TURN credentials per session, so copy them from a live call:chrome://webrtc-internalslists the ICE servers (with username and credential) the app's peer connection was given. From a network that only allows 80/443 you should see arelaycandidate — that is TURN/TLS working. From an unrestricted network you'll also seesrflx/hostcandidates. -
From a real call. In
chrome://webrtc-internals(or the app's DevTools), check the selected candidate pair of a call on the restricted network. A working TURN fallback shows a pair whose local or remote candidate isrelaywith aturns:/transport=tcpline. -
Simulate a blocked network. To confirm the fallback path without a restrictive carrier, temporarily block inbound UDP 7882 and see whether the call still connects (it should fall back to TURN).
Nothing relayed?
If candidates gather but media never flows, the relay range is almost certainly not reachable. Confirm UDP 50000–50100 (all 101 ports, inclusive) is forwarded end to end — firewall, router, and LoadBalancer — and matches relay_range_start/relay_range_end exactly. If relay candidates never appear at all, the SNI router or the TLS certificate for turn.example.com is the problem: verify the certificate matches the turn.domain and that 443 is answered by the SNI router, not the reverse proxy.