How to Run a TURN Server with coturn (Make Nextcloud Talk and Matrix Calls Work from Anywhere)

Nextcloud Talk (32), Matrix/Element calls (126), Jitsi — all use WebRTC, which tries to connect the two phones directly. On the same Wi-Fi that works; from a café on 4G it often doesn't, because both ends are behind NAT and neither can reach the other. STUN helps them discover their addresses; when that isn't enough, TURN relays the media through a server both can reach — yours. coturn is the standard implementation and it's one container plus some ports.
Quick reference
| Piece | What |
|---|---|
| Container | coturn/coturn with host networking (it needs many UDP ports) |
| Ports | 3478 tcp/udp (STUN/TURN), 5349 tcp (TLS), 49160–49200/udp (relay range) |
| Auth | use-auth-secret + static-auth-secret shared with the app |
| Public name | turn.example.com → your public IP (not Cloudflare-proxied) |
| Nextcloud Talk | Settings → Talk → TURN server: turn.example.com:3478, the secret, udp+tcp |
| Matrix | turn_uris, turn_shared_secret in the homeserver config |
| Test | Trickle ICE page (webrtc.github.io/samples) shows a relay candidate |
1. The config
mkdir -p ~/docker/coturn && cd ~/docker/coturn
openssl rand -hex 32 # the shared secret
nano turnserver.conf
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
external-ip=203.0.113.10 # your public IP; see note for dynamic
realm=turn.example.com
min-port=49160
max-port=49200
use-auth-secret
static-auth-secret=PASTE_THE_HEX_SECRET
fingerprint
lt-cred-mech
no-multicast-peers
no-cli
cert=/certs/fullchain.pem
pkey=/certs/privkey.pem
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
allowed-peer-ip=192.168.1.50 # your own server, so Talk/Matrix can be relayed to
log-file=stdout
The denied-peer-ip lines stop the relay being used to reach your LAN. external-ip matters behind a home router; with a dynamic IP (48), a small script that rewrites it and restarts the container when it changes, or run coturn on a VPS where the IP is fixed — it's a common choice since relays want bandwidth you may not have at home.
2. The compose file
nano compose.yaml
services:
coturn:
image: coturn/coturn:latest
container_name: coturn
network_mode: host
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
- /etc/letsencrypt/live/turn.example.com:/certs:ro
restart: unless-stopped
Host networking because the relay range is 40 ports and Docker's per-port NAT is slow for media. The certificate (27, or the wildcard, 147) enables TURNS on 5349 for networks that block everything but TLS.
docker compose up -d
sudo ufw allow 3478 && sudo ufw allow 5349/tcp && sudo ufw allow 49160:49200/udp
Router: forward 3478 tcp+udp, 5349 tcp, 49160–49200 udp to the server. turn.example.com is an A record to your public IP — not through Cloudflare's proxy.
3. Tell the apps
Nextcloud Talk: Administration → Talk → TURN servers → Add: turn.example.com:3478, the shared secret, protocol UDP and TCP. Also set a STUN server (turn.example.com:3478 works for both). The test button next to it should go green.
Matrix (Conduit/Synapse): in the homeserver config — Conduit: CONDUIT_TURN_URIS: '["turn:turn.example.com:3478?transport=udp","turn:turn.example.com:3478?transport=tcp"]' and CONDUIT_TURN_SECRET. Element then uses it for calls automatically.
Jitsi / others: the same URI and secret in their TURN settings.
4. Test it
Open the WebRTC Trickle ICE sample page, add turn:turn.example.com:3478 with a username/credential generated from the secret (the page explains the time-limited format; turnutils_uclient in the container does the same from the command line). Gather candidates: a line of type relay with your public IP means TURN is working. Then a real test: a Talk call between a phone on mobile data and a laptop at home.
STUN vs TURN, in one breath
STUN tells a device its public address so two devices can try to talk directly — cheap, often enough. TURN carries the stream when direct fails — costs your bandwidth (both ends' traffic passes through), so each call uses roughly 1–3 Mbps up and down on your connection. A handful of family calls is fine on a home line; a busy Matrix server wants a VPS.
Troubleshooting
No relay candidate. The ports aren't reaching coturn (router forward, UFW), or external-ip is wrong. docker compose logs coturn shows "allocation" lines when it works.
Calls connect on Wi-Fi, still fail on 4G. The app isn't using TURN — check the secret matches exactly and the URI includes ?transport=.
"401 Unauthorized" in the logs. Credentials generated with a different secret, or clock skew (the credentials are time-based; timedatectl).
Works, then stops after the IP changes. external-ip and the DNS record; the update script, or a VPS.
Wrapping up
One container with host networking, a shared secret, five port forwards, a name that isn't proxied. Nextcloud Talk and Element calls connect from mobile data, the relay only runs when direct fails, and the media path is still a server you own.
