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

Calls that fail on mobile data: a TURN relay fixes it. coturn in one container, a shared secret, and Nextcloud Talk and Matrix calls connect 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

PieceWhat
Containercoturn/coturn with host networking (it needs many UDP ports)
Ports3478 tcp/udp (STUN/TURN), 5349 tcp (TLS), 49160–49200/udp (relay range)
Authuse-auth-secret + static-auth-secret shared with the app
Public nameturn.example.com → your public IP (not Cloudflare-proxied)
Nextcloud TalkSettings → Talk → TURN server: turn.example.com:3478, the secret, udp+tcp
Matrixturn_uris, turn_shared_secret in the homeserver config
TestTrickle 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.

Call us