Your WebRTC app works on your Wi-Fi and fails for the one customer calling from a hotel. Peer-to-peer media needs a path between the two sides; when both are behind strict NAT, or a firewall drops UDP, there's no path and the call just doesn't connect. Nobody sees an error — just an endless „connecting…“.
A TURN server is the fallback for those calls: both sides send their media to it, and it relays. If you're building video or voice into an app, running Matrix or Jitsi for a team, or doing voice chat for a game, you want one. coturn is the standard open-source implementation, and it's light.
Which plan
TURN is cheap on CPU and memory. What it uses is bandwidth: each relayed stream goes through the server twice, in and out. Traffic on our plans is unmetered at 1 Gbit/s, so the question is how many relayed calls run at once, not how much you'll be billed.
| Relayed calls at the same time | Plan |
|---|---|
| A handful — a team, a small app | Nano-IP — 2 vCPU, 1 GB, 15 GB ($8/mo) |
| Dozens | Micro-IP — 2 vCPU, 2 GB, 25 GB ($10/mo) |
| More, or TURN next to your app server | Small-IP — 4 vCPU, 4 GB, 35 GB ($16/mo) |
It has to be a plan with its own IPv4 — TURN is all about inbound connections on many ports, and NAT plans only accept SSH from outside. On our dedicated-IP plans the public address sits right on the server's interface, so coturn finds it without an external-ip line. Put the server close to your users: Germany or Finland, availability is shown at checkout. Crypto payment, no KYC, if that matters to you.
Ports to open: 3478 UDP and TCP, 49152–65535 UDP for the relayed media, and 5349 TCP if you add TLS.
Install and configure
As root on Ubuntu 24.04 or Debian 12:
apt install -y coturn
ip -4 addr # your public IP should be listed on the interface
Write the config with a fresh shared secret:
SECRET=$(openssl rand -hex 32)
cat > /etc/turnserver.conf <<EOF
listening-port=3478
fingerprint
use-auth-secret
static-auth-secret=$SECRET
realm=turn.example.com
min-port=49152
max-port=65535
no-cli
no-multicast-peers
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
EOF
echo "$SECRET" # your backend needs this value
systemctl restart coturn
ufw allow 22/tcp && ufw allow 3478 && ufw allow 49152:65535/udp && ufw enable
Don't skip the denied-peer-ip lines. Without them, anyone with valid credentials can use your relay to reach private addresses — including services on the server's own internal side. We checked: with these lines a relay to a private address is refused with „403 Forbidden IP“.
Credentials that expire
With use-auth-secret, coturn accepts a username that contains an expiry time and a password that's an HMAC of it. Your backend generates them; the secret never leaves the server side. In Node.js:
const crypto = require("crypto");
// Temporary TURN username/password (the TURN REST API scheme coturn understands via use-auth-secret).
function turnCredentials(secret, user, ttlSeconds = 3600) {
const username = `${Math.floor(Date.now() / 1000) + ttlSeconds}:${user}`;
const credential = crypto.createHmac("sha1", secret).update(username).digest("base64");
return { username, credential };
}
console.log(JSON.stringify(turnCredentials(process.env.TURN_SECRET, "alice")));
In the browser, ask your backend for a fresh pair and hand it to RTCPeerConnection:
const { username, credential } = await fetch("/api/turn-credentials").then((r) => r.json());
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:turn.example.com:3478" },
{
urls: ["turn:turn.example.com:3478?transport=udp", "turn:turn.example.com:3478?transport=tcp"],
username,
credential,
},
],
});
Test it
From the server, using the same scheme with openssl (it produces exactly the same password as the Node.js code — we compared):
U="$(( $(date +%s) + 3600 )):test"
P=$(echo -n "$U" | openssl dgst -binary -sha1 -hmac "$SECRET" | base64)
turnutils_uclient -y -n 10 -u "$U" -w "$P" YOUR_SERVER_IP
At the end you want „Total lost packets 0“. With a wrong password the client stops at „Cannot complete Allocation“ — that's the server doing its job.
We also checked from a real Chromium: with iceTransportPolicy: "relay" and fresh credentials the browser got relay candidates from the server; with a bad password it got none. If you want to test your own setup the same way, set that option for a moment — it forces every call through TURN.
TLS, if you need it
Some networks block UDP and are picky about plain TCP; TURN over TLS gets through more of them. Get a certificate for turn.example.com (certbot is fine). Its files are readable by root only, while coturn runs as the turnserver user, so give coturn its own copy:
mkdir -p /etc/coturn
cp /etc/letsencrypt/live/turn.example.com/fullchain.pem /etc/letsencrypt/live/turn.example.com/privkey.pem /etc/coturn/
chown turnserver: /etc/coturn/*.pem && chmod 640 /etc/coturn/privkey.pem
cat >> /etc/turnserver.conf <<'EOF'
tls-listening-port=5349
cert=/etc/coturn/fullchain.pem
pkey=/etc/coturn/privkey.pem
EOF
systemctl restart coturn && ufw allow 5349/tcp
Add turns:turn.example.com:5349?transport=tcp to the urls list. Certificates are short-lived, so put the copy into a certbot deploy hook — otherwise TLS quietly breaks at the first renewal.
Worth knowing
- TURN doesn't make a bad connection good. It adds one hop, so place the server near the people who need it.
- If you run an SFU — Jitsi's videobridge, for example — that's a separate piece. TURN helps the clients that can't reach it directly.
- The secret is the whole security model. Keep it out of the frontend, and rotate it if it ever leaks.
Related
- VPS for Jitsi Meet — video calls on your own server
- Self-host Matrix Synapse — Matrix calls need a TURN server too
- VPS for a website on your own IP — the app that hands out the credentials
- How to configure a ufw firewall
Comments
No comments yet. Be the first.