Configuring Coturn for Production TURN Relay
This deep-dive turns a default coturn install into a hardened production relay: a correct turnserver.conf, public-IP mapping, HMAC authentication, resource limits, and the exact commands to verify an allocation. It is part of the TURN Server Configuration & Auth section under the broader WebRTC Protocol Stack & Signaling Servers guide. The specific decision here is which directives are non-negotiable for a relay that authenticates real users and survives symmetric NAT β and which defaults will silently break media if you leave them in place.
Context & Trade-offs
A relay only matters when ICE has exhausted host and srflx candidates. By then the call has already spent time gathering, so an additional 10β15 seconds of slow allocation pushes Chromeβs ICE agent toward a failed state before the relay pair is even tried β and the difference between a recoverable stall and a terminal one is exactly what Disconnected vs Failed ICE States pulls apart. Two configuration choices dominate reliability. First, external-ip: on every cloud instance the kernel sees only the private RFC 1918 address, so coturn must be told the public address explicitly or it advertises an unroutable relay candidate. Second, the relay port range: media flows through min-portβmax-port (conventionally 49152β65535), not through the 3478 control port, so a firewall that opens only 3478 lets allocation succeed and then drops every media packet.
external-ip.Transport coverage is the other axis. UDP on 3478 is the fast path, but corporate networks routinely block UDP and restrict outbound to 80/443. no-tcp-relay saves resources on a relay that only serves consumer traffic, but it must be removed the moment you need to serve users behind DPI proxies β those clients require TCP, ideally TLS on 443 via turns://, a setup covered end to end in Forcing TURN over TCP 443 on Locked-Down Networks. Keep relay nodes in the same regions as your STUN Server Deployment Strategies endpoints β the placement maths is worked through in Choosing STUN Server Regions for Latency β so allocation latency stays low and clients prefer the cheaper srflx path before falling back to relay.
The 20β40 ms one-way penalty a relay adds is a property of geometry, not of coturnβs forwarding path. Media leaves the client, terminates on the relay, and is re-emitted toward the peer, so the end-to-end delay becomes the sum of two legs instead of one; a badly placed node turns a 15 ms same-city path into a 150 ms triangle across an ocean and back. Per-packet framing compounds it. Until a channel is bound, every relayed frame travels inside a STUN Send or Data indication with a 36-byte header; once ChannelBind succeeds the wrapper collapses to a 4-byte ChannelData header. At 50 audio packets per second in each direction that is the difference between roughly 14 kbps and 1.6 kbps of pure framing overhead per stream, which is why Chrome and Firefox both issue ChannelBind immediately after installing the first permission rather than living on indications.
Permissions and bindings are also why a relay that worked yesterday starts dropping packets from a peer it already knows. A TURN permission β the relayβs record that this allocation may exchange packets with one specific peer address β lives 300 seconds; a channel binding lives 600 seconds; the allocation itself is granted for whatever max-allocate-lifetime permits, capped at 3600 seconds in the config below. All three are re-armed by the client while media flows, so an hour-long call never trips the cap. What the cap actually buys is reclamation: when a browser tab dies without sending a Refresh with lifetime 0, its relay port and quota slot stay held until the grant expires, so a lower cap returns capacity faster after mass client crashes. That whole permission dance exists so the relay cannot be turned into a packet cannon aimed at a third party, and it is the same machinery that makes relay candidates work against the address-and-port-dependent mappings dissected in Traversing Symmetric NAT with TURN.
Sizing a relay node
Three ceilings decide how many calls a node carries, and the port range is almost never the binding one. The 49152β65535 span yields 16,384 relay ports, and with rtcp-mux and BUNDLE a browser consumes a single allocation per peer connection, so ports outlast bandwidth by an order of magnitude. Bandwidth is the real ceiling: relayed video at 1.5β2.5 Mbps per direction saturates a 1 Gbps interface somewhere around 200β300 concurrent relayed streams, while total-quota=1000 combined with max-bps=5000000 describes a theoretical 5 Gbps that no 1 Gbps NIC can honour. Derive total-quota from the interface, not from the port count, and remember that relayed traffic is billed twice by most cloud providers β in and out β so a relay carrying 300 Mbps sustained is an egress line item, not a rounding error.
CPU is the loosest of the three, and the reason is structural: coturn never touches media keys. DTLS-SRTP is negotiated end to end between the two peers, so the relay forwards opaque ciphertext and physically cannot transcode, inspect, or repair it, which leaves one modern core handling several hundred allocations of socket-to-socket copying. The corollary matters during incident review β a freeze or artefact that survives on the relay path is an encoder, loss, or congestion problem, not a turnserver.conf problem.
Minimal Runnable Implementation
Disable every protocol you do not need, bind explicitly, and map the public address. This baseline authenticates with the REST-API HMAC model rather than a per-user database.
# /etc/turnserver.conf β production baseline
listening-ip=0.0.0.0 # bind all interfaces; coturn selects per request
listening-port=3478 # STUN + plain TURN (UDP/TCP)
tls-listening-port=5349 # turns:// over TLS
external-ip=203.0.113.10/10.0.1.5 # PUBLIC_IP/PRIVATE_IP β public first, non-negotiable
realm=turn.yourdomain.com # auth realm advertised in the 401 challenge
server-name=turn.yourdomain.com
min-port=49152 # first relay media port β open the whole range in the firewall
max-port=65535 # last relay media port
no-multicast-peers # block relays to multicast addresses (abuse vector)
no-tcp-relay # drop TCP relay allocations β REMOVE if UDP-blocked clients exist
lt-cred-mech # enable long-term credential mechanism (required for HMAC)
use-auth-secret # REST-API shared-secret model, no per-user DB rows
static-auth-secret=<YOUR_32_BYTE_SECRET> # HMAC key; supports multiple lines for rotation
stale-nonce=600 # rotate nonce every 600s to block handshake replay
fingerprint # add FINGERPRINT attribute; strict WebRTC clients require it
max-bps=5000000 # per-allocation cap β 5 Mbps ceiling on relayed throughput
user-quota=10 # max simultaneous allocations per credential
total-quota=1000 # max simultaneous allocations on the node
max-allocate-lifetime=3600 # purge stale allocations after 1 hour to reclaim ports
log-file=/var/log/turnserver/turn.log
verbose
no-tcp-relay disables TCP-based relay allocations; remove that single line when clients on UDP-blocking firewalls need to reach you. Enabling static-auth-secret without lt-cred-mech is a silent no-op β the HMAC mechanism never engages and every authenticated Allocate is rejected. The credential format your backend must produce is username = ${expiry}:${userId} with the credential being the base64 HMAC-SHA1 of that username; the signing code and TTL guidance live in Time-Limited TURN Credentials with HMAC.
Hardening beyond the baseline
The baseline authenticates users; it does not stop an authenticated user from pointing the relay at your own infrastructure. A TURN allocation will happily relay to any peer address the client names, including 169.254.169.254 and every RFC 1918 range inside your VPC, which makes an unrestricted relay a credentialed proxy into the cloud metadata service and an internal port scanner. Peer filtering is the fix, and it belongs in the config from day one rather than after the first security review.
# /etc/turnserver.conf β hardening additions on top of the baseline
denied-peer-ip=10.0.0.0-10.255.255.255 # private range β your own VPC
denied-peer-ip=169.254.0.0-169.254.255.255 # link-local: cloud metadata lives here
denied-peer-ip=192.168.0.0-192.168.255.255 # private range (repeat for 172.16/12)
allowed-peer-ip=10.0.2.40 # exception: your own SFU node, if it relays
cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem # turns:// on 5349
pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem # reload after renewal
cipher-list="ECDHE+AESGCM:ECDHE+CHACHA20" # drop legacy suites DPI proxies choke on
proc-user=turnserver # drop root after binding privileged ports
proc-group=turnserver
no-cli # disable the telnet admin CLI on 5766 entirely
syslog # ship to journald; drop `verbose` once the node is live
Version behaviour differs across the packages you are likely to hit. Ubuntu 22.04 ships coturn 4.5.2, where loopback and multicast peers must be denied explicitly; Debian 12 and Ubuntu 24.04 ship 4.6.x, which denies both by default and treats no-loopback-peers as deprecated, with the inverse allow-loopback-peers reserved for hosts where the relay and an SFU sit side by side. The 4.5.x Debian packaging also gates the daemon behind TURNSERVER_ENABLED=1 in /etc/default/coturn; leave it unset and systemctl start coturn reports success while nothing binds. Certificate renewal is the other recurring operational trap: coturn reads cert and pkey once at startup, so a Letβs Encrypt renewal silently keeps serving the expired chain on 5349 until a systemctl reload coturn runs from the renewal hook.
Reproduction Steps & Debugging Log Patterns
The five steps below drive one complete authenticated allocation, from the unauthenticated probe that earns a 401 challenge to the echoed messages that prove media traverses the relay.
- Apply the config and restart the daemon:
systemctl restart coturn. Confirm it bound the listeners withss -lunp | grep turnserverβ you should see UDP/TCP on 3478 and TLS on 5349. - Mint a test credential from your backend (or inline with
openssl) and drive a real allocation throughturnutils_uclient:
# Real Allocate + 10 relayed messages using an HMAC credential
turnutils_uclient \
-u "1780000000:alice" \
-w "$(printf '%s' '1780000000:alice' \
| openssl dgst -sha1 -hmac "$TURN_SECRET" -binary | base64)" \
-y -m 10 turn.yourdomain.com
- Tail the daemon log and watch for the allocation line:
journalctl -u coturn -f \
| grep -E "relayed address .* (allocated|not allocated)"
- Expected success:
INFO: session <id>: relayed address 203.0.113.10:51234 allocated, with the IP matching the public half ofexternal-ipand the port inside 49152β65535. A401/403in the log means the HMAC did not match β check the secret and theexpiry:userIdusername format.ERROR: β¦ not allocatedmeans a firewall is blocking the relay range orexternal-ipis wrong. - Confirm media actually flows:
turnutils_uclientprints sent/received message counts; a successful relay shows all 10 messages echoed. From a browser,pc.getStats()should report a succeeded candidate pair withlocalCandidateType === 'relay'. Trend that relay share alongside the reflexive-path health you already watch when Monitoring STUN Binding Success Rates β a sudden jump in relay usage usually means STUN is failing upstream, not that TURN improved.
Forcing the relay path from a real browser
turnutils_uclient proves the daemon works; it does not prove that a browser, with its own candidate preferences, will ever choose the relay. ICE prefers host and srflx pairs by priority, so on a healthy office network the relay is gathered and never used, and a broken relay stays invisible until the first user on a symmetric NAT calls in. Pin the transport policy to relay in a staging build and every pair becomes a relay pair, which turns a silent misconfiguration into an immediate iceConnectionState === 'failed'.
// Staging-only probe: refuse every non-relay candidate so TURN must carry the call
const pc = new RTCPeerConnection({
iceTransportPolicy: 'relay', // drop host + srflx candidates entirely
iceServers: [{
urls: ['turn:turn.yourdomain.com:3478?transport=udp'],
username: turnCreds.username, // "<expiry>:<userId>" from your backend
credential: turnCreds.credential // base64 HMAC-SHA1 of that username
}]
});
// A relay-only connection that never leaves 'checking' means allocation failed,
// not that the network is slow β fail loudly instead of waiting out the timeout.
const guard = setTimeout(() => {
if (pc.iceConnectionState === 'checking') {
console.error('[turn] no relay pair after 5s β check allocation + port range');
}
}, 5000); // 3β5 s is the usual fallback budget
pc.onconnectionstatechange = async () => {
if (pc.connectionState !== 'connected') return;
clearTimeout(guard);
const stats = await pc.getStats(); // poll at 1 s intervals for ongoing checks
stats.forEach(report => {
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
const local = stats.get(report.localCandidateId);
// relayProtocol tells you whether the allocation is udp, tcp, or tls
console.info('[turn] relay via', local.relayProtocol, local.address);
}
});
};
The same evidence is available without instrumenting your app: the candidate grid and connection log in Reading chrome://webrtc-internals Dumps list each relay candidate with its relayProtocol, and a relay candidate that appears but never enters a succeeded pair is the browser-side twin of the not allocated log line. Safari is the outlier: it surfaces nothing until the ICE checklist times out, which is why the 5-second guard above beats waiting for a state change there.
Common Implementation Mistakes
- Omitting
external-ipbehind cloud NAT β coturn returns the private instance IP as the relay address, unreachable from public peers, and the call fails silently behind symmetric NAT. - Inverting the
external-ipformat β the order isPUBLIC_IP/PRIVATE_IP, public first; reversing it advertises the wrong address. static-auth-secretwithoutlt-cred-mechβ HMAC credentials require the long-term mechanism active, or every authenticated allocation is rejected.- Blocking the relay port range β opening only 3478 lets allocation succeed but drops all media; open
min-portβmax-portfor both UDP and TCP. - Forgetting
fingerprintβ disables message integrity and breaks strict WebRTC clients that validate TURN message authenticity. - Shipping
verboseto production β it logs every allocation, permission, and channel binding per session; at a few hundred concurrent allocations the file grows by gigabytes a day and the synchronous write stalls the relay loop under log rotation. Keep it for the first hour of a rollout, then switch tosyslogwith journald retention. - Inheriting the packaged file-descriptor limit β each allocation costs multiple sockets, so a unit still running the old
LimitNOFILE=1024default fails new allocations abruptly at a few hundred sessions with socket-creation errors while CPU and bandwidth sit near idle. Add a drop-in withLimitNOFILE=65535before you load-test, or the ceiling you measure is systemdβs, not coturnβs. - Reading
max-allocate-lifetimeas a call-duration cap β it bounds the lifetime granted per Allocate and Refresh, and browsers refresh well inside it, so lowering it to 600 does not disconnect long meetings; it only reclaims ports faster from clients that vanished without a teardown.
Each of those mistakes produces a distinct signature in turn.log, so triage from the log line rather than from the browser symptom.
FAQ
Why does Coturn allocate relay addresses but WebRTC peers still fail to connect?
Almost always a misconfigured external-ip: ICE is advertising the private RFC 1918 address instead of the public one. Verify the allocated relay IP in the log matches your public interface, and confirm UDP and TCP are open across the entire min-portβmax-port range in your security group.
Can I run Coturn without a signalling server? No. Coturn only handles media relay allocation; it does not exchange SDP. You still need a separate signalling path β see WebSocket Signaling Implementation β to deliver offers, answers, and the ephemeral credentials.
How do I rotate static-auth-secret without dropping active sessions?
Append the new secret as an additional static-auth-secret line, run systemctl reload coturn, and wait for the longest outstanding credential TTL to expire before removing the old line. Both secrets validate during the overlap, so no active allocation is interrupted.
How many concurrent relayed calls should one node be sized for?
Work from the NIC, not from the port range. A two-party call with video relayed in both directions moves roughly 3β5 Mbps through the node, so a 1 Gbps instance realistically carries 200β300 relayed streams before queuing delay pushes the relay leg past its usual 20β40 ms. Set total-quota to that measured number rather than to the 16,384 available ports, and treat anything above 60% sustained NIC utilisation as the signal to add a region instead of a bigger box.
Does moving clients to TLS on 5349 cost latency?
The handshake costs one extra round trip at setup, which is invisible against a 3β5 s fallback budget. The steady-state cost is the transport, not the crypto: TLS runs over TCP, so a single lost segment head-of-line-blocks every packet behind it, and jitter under loss is markedly worse than plain UDP relay. Keep turn: on 3478 UDP first in the iceServers list and let turns: on 5349 exist purely as the fallback for networks that block UDP.
Related: return to TURN Server Configuration & Auth, pair this with Time-Limited TURN Credentials with HMAC, and co-locate nodes with your STUN Server Deployment Strategies.