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.

Control plane and media plane of a production coturn relay A browser client reaches coturn through a cloud firewall that must open both port 3478 for control and authentication and the 49152 to 65535 range for relayed media. Inside the node, external-ip maps the private address 10.0.1.5 to the public address 203.0.113.10, which is what peers on the public internet receive as the relay candidate. Control plane 3478 β€” media plane 49152–65535 Browser client symmetric NAT Cloud firewall 3478 UDP/TCP control + auth 49152–65535 relayed media Open both ranges or media dies coturn node private 10.0.1.5 all the kernel sees external-ip public 203.0.113.10 advertised to peers max-bps 5 Mbps cap relay adds 20–40 ms Remote peer public internet Allocate relayed media
Allocation rides the 3478 control port; every media packet leaves on the relay range with the public address from 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.

turnserver.conf directive groups and the failure each one prevents The production config divides into four blocks. Binding and identity carries listening-port 3478, TLS 5349 and external-ip. The relay port range carries min-port 49152, max-port 65535 and no-tcp-relay. HMAC authentication carries lt-cred-mech, use-auth-secret, static-auth-secret and stale-nonce 600. Resource limits carry max-bps, user-quota, total-quota and allocation lifetime. Each block is annotated with the production failure it prevents. turnserver.conf what breaks without it Binding & identity listening-port=3478 Β· tls-listening-port=5349 external-ip=203.0.113.10/10.0.1.5 Relay port range min-port=49152 Β· max-port=65535 no-tcp-relay (drop it for TCP clients) HMAC authentication lt-cred-mech Β· use-auth-secret static-auth-secret Β· stale-nonce=600 Resource limits max-bps=5000000 Β· user-quota=10 total-quota=1000 Β· lifetime=3600 Public IP must come first, or peers get an unroutable candidate Allocate succeeds on 3478 while every media packet is dropped A secret without lt-cred-mech is a silent no-op β€” 401 on every try One stolen credential can drain the node's ports and egress bill
Four directive groups, and the specific production failure each one exists to prevent.

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.

The authenticated TURN allocation exchange produced by turnutils_uclient turnutils_uclient sends an Allocate with no credential. coturn on port 3478 replies 401 with a realm and nonce. The client retries with an HMAC credential whose username is expiry colon userId. coturn verifies the HMAC against the shared secret and the 600 second stale-nonce window, then logs a relayed address as allocated on a port inside the relay range, after which ten messages echo back through it. turnutils_uclient coturn :3478 relay port 51234 Allocate (no credential) 401 Β· realm + nonce Allocate + HMAC 1780000000:alice verify HMAC Β· stale-nonce 600 s relayed address allocated 10 messages echoed Β· getStats reports a relay pair A second 401 after the HMAC retry means the secret or the expiry:userId format is wrong
One authenticated allocation: challenge, HMAC retry, relayed address, echoed media.
  1. Apply the config and restart the daemon: systemctl restart coturn. Confirm it bound the listeners with ss -lunp | grep turnserver β€” you should see UDP/TCP on 3478 and TLS on 5349.
  2. Mint a test credential from your backend (or inline with openssl) and drive a real allocation through turnutils_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
  1. Tail the daemon log and watch for the allocation line:
journalctl -u coturn -f \
  | grep -E "relayed address .* (allocated|not allocated)"
  1. Expected success: INFO: session <id>: relayed address 203.0.113.10:51234 allocated, with the IP matching the public half of external-ip and the port inside 49152–65535. A 401/403 in the log means the HMAC did not match β€” check the secret and the expiry:userId username format. ERROR: … not allocated means a firewall is blocking the relay range or external-ip is wrong.
  2. Confirm media actually flows: turnutils_uclient prints sent/received message counts; a successful relay shows all 10 messages echoed. From a browser, pc.getStats() should report a succeeded candidate pair with localCandidateType === '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

Each of those mistakes produces a distinct signature in turn.log, so triage from the log line rather than from the browser symptom.

Triage tree from coturn log signature to configuration fix Starting from a failing relay, three log signatures split the diagnosis. A 401 or 403 points at the shared secret and the expiry colon userId username format. An allocation that succeeds with no packets relayed points at a firewall that never opened 49152 to 65535 for UDP and TCP. A relay address in the 10.0.1.5 range points at a missing or inverted external-ip directive. Relay not working start from the log line 401 / 403 in turn.log Allocate rejected allocated, no media 0 packets relayed relay IP is 10.0.1.5 peers cannot reach it Re-check the secret and lt-cred-mech, then the expiry:userId name Open 49152–65535 for UDP and TCP in the security group Set external-ip as PUBLIC_IP/PRIVATE_IP public address first Re-run turnutils_uclient -y -m 10 after each change before touching the next directive
Triage from the log signature, not the browser symptom β€” each branch has exactly one fix.

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.