Skip to content

Calls and TURN

Call media is peer to peer. That reaches most of the internet, but not all of it, and the failure is the worst possible shape: signaling succeeds, the person appears in the participant list, and there is simply silence. No error, nothing to click.

Your laptop has no address the outside world can dial. Your router does, shared among everything behind it. WebRTC works around this by asking a STUN server what address it sees you from, then advertising that to the other side. Your outbound packet punched a hole in the NAT, so the reply comes back through it.

That assumes your router reuses one mapping for every destination. Most home routers do. Symmetric NAT does not: it allocates a different external port per destination, so the address the STUN server reports is only good for talking to the STUN server. Both sides advertise addresses that lead nowhere. This is common on corporate networks and mobile carriers.

Networks that block UDP outright fail the same way.

A TURN server is a relay you can rent an address from. You authenticate, ask for an allocation, and it sets aside a public address that forwards to you. Because you opened that connection yourself, the return path works no matter how hostile your NAT is.

ICE still prefers a direct path and only falls back to the relay when nothing else connects, so adding TURN does not route everyone through your server.

Terminal window
# in .env
TURN_EXTERNAL_IP=203.0.113.10 # this machine's public IP
TURN_SECRET=$(openssl rand -hex 32)
docker compose -f docker/compose.yml -f docker/compose.turn.yml \
--env-file .env up -d

Open 3478 UDP and TCP, 5349 for TLS, and the relay range 49152 to 50175 UDP in the firewall and any cloud security group. That range is why a relay cannot sit behind nginx: it is not HTTP traffic. The bundled coturn reuses Caddy’s certificate for turns://.

The relay range is the part people skip, because opening a block of UDP ports looks alarming next to three tidy TCP ones. Skip it and a call negotiates, connects, and stays silent: the relay hands out a port from that range and names it inside the protocol, so a blocked range fails after everything that would have warned you has already succeeded. It is 1024 ports rather than coturn’s default 16384, which is more than a self-hosted workspace will hold at once and a much smaller thing to leave open.

To use a relay you already run, set TURN_URLS and TURN_SECRET and skip the compose file.

Slick issues them per request from GET /api/ice, using coturn’s use-auth-secret scheme: the username is <expiry>:<user-id> and the password is base64(HMAC-SHA1(secret, username)). The shared secret never leaves the server.

This is not ceremony. A relay with fixed credentials in your web bundle is an open relay for anyone who opens devtools, billed to you. Setting TURN_URLS without TURN_SECRET therefore fails at startup.

Relayed traffic is paid for twice, inbound and outbound. Calls are a full mesh, so a participant in a four person call sends three copies of their stream. One relay-only participant with video can mean several Mbps sustained. docker/turnserver.conf ships with per-allocation and total quotas. Read them before opening this to a crowd.

Open devtools on a call and look for a candidate of type relay. If you see 401 Unauthorized against your TURN URL, the secret does not match. If you see no relay candidate and no error, TURN_URLS is not reaching the browser: check GET /api/ice in the network tab.