Setting Up a Coturn TURN Server: Complete Installation Guide

Installing coturn takes one command. Getting it to actually relay WebRTC traffic takes about six more decisions, and four of them are the kind that produce a server which starts cleanly, authenticates correctly, logs successful allocations, and relays nothing.

This guide is organised around that gap. The installation section is short because installation is short. The sections after it cover the settings that decide whether the thing works: external-ip on a cloud instance, the relay port range, which credential mechanism you picked, and what happens to your TLS certificate ninety days from now.

What has to be reachable

Coturn listening ports and firewall requirements A WebRTC client reaches coturn on 3478 (UDP and TCP) for STUN and TURN, and on 5349 (TCP) for TURN over TLS. Coturn allocates relay ports in the 49152 to 65535 UDP range. Every one of these must be open in the firewall. WebRTC client browser / app coturn server 3478 /udp,tcp — STUN & TURN 5349 /tcp — TURN over TLS 49152–65535 /udp — relay all must be open in the firewall
Figure 1 — The ports coturn listens on. The wide relay range (49152–65535/UDP) is the one most often forgotten in firewall rules — and the reason TURN "authenticates but never relays".

Three ranges, and the third is the one that gets forgotten:

PortProtocolPurpose
3478UDP and TCPSTUN, and TURN control (turn:)
5349TCPTURN over TLS (turns:)
49152–65535UDPRelay allocations — the actual media path

If the relay range is closed, clients authenticate successfully, the server allocates a relayed address, your logs show allocations, your monitoring shows port 3478 listening — and no media ever flows. This is the single most common coturn misconfiguration, and every signal you normally trust reports the server as healthy while it happens.

Installation

On Debian and Ubuntu, coturn is in the main archive:

sudo apt update
sudo apt install coturn

On RHEL, Rocky, and AlmaLinux it is not in the base repositories — it comes from EPEL:

sudo dnf install epel-release
sudo dnf install coturn

Build from source only if you need a version newer than your distribution ships, or a build option the package does not enable. For the common case the packaged build is fine and gets security updates without your involvement.

Debian/Ubuntu gotcha: the packaging historically ships /etc/default/coturn with the line TURNSERVER_ENABLED=1 commented out, and the service refuses to start until it is uncommented. If systemctl start coturn reports success and nothing is listening, check that file first.

A working configuration, annotated

Everything lives in /etc/turnserver.conf. Here is a configuration that works, with the reasoning attached — copy it, then read the sections below for the parts that depend on your environment.

# /etc/turnserver.conf

listening-port=3478
tls-listening-port=5349

# The address clients should be told to use. See "Cloud instances" below —
# on any provider where the machine does not hold its public IP directly,
# this line is the difference between working and silently not working.
external-ip=203.0.113.7

# Must match the realm your application uses when generating credentials.
realm=turn.example.com
server-name=turn.example.com

# --- Authentication -------------------------------------------------
# REST / time-limited credentials. Your backend derives a username and
# HMAC password from this same secret; coturn validates without a database.
use-auth-secret
static-auth-secret=REPLACE_WITH_A_LONG_RANDOM_STRING

# --- TLS for turns: --------------------------------------------------
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
no-tlsv1
no-tlsv1_1

# --- Relay range -----------------------------------------------------
# Narrower than the default so the firewall rule stays manageable.
# Each concurrent allocation consumes one port: size this above your
# expected peak concurrent relayed sessions, with headroom.
min-port=49160
max-port=49200

# --- Abuse prevention (do not skip this) -----------------------------
# Without these, your relay will forward traffic to private addresses,
# which makes it a proxy into your own VPC and into the internal networks
# of anyone else who finds it.
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=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

# --- Quotas ----------------------------------------------------------
# user-quota: allocations allowed per user.
# total-quota: allocations allowed across the whole server.
user-quota=12
total-quota=1200

# max-bps is BYTES per second, per session — not bits, not per user.
# 250000 bytes/s = 2 Mbps, comfortable headroom for one video call.
max-bps=250000

# --- Operations ------------------------------------------------------
log-file=/var/log/turnserver.log
simple-log
pidfile=/var/run/turnserver.pid
proc-user=turnserver
proc-group=turnserver
fingerprint
no-cli

Two of those deserve emphasis because they are the ones most often copied wrong from other guides:

  • user-quota is per user; total-quota is server-wide. Several widely-copied configurations have these annotated backwards, which produces either a server that refuses almost every allocation or one with no meaningful limit at all.
  • max-bps is measured in bytes per second and applies per session. A value of 1000000 is not "1 Mbps" — it is 8 Mbps.

Cloud instances: the external-ip line

On AWS, GCP, Azure, and most other providers, your instance does not hold its public IP on an interface. It sees a private address; the provider's fabric does the translation. coturn, left to itself, advertises the address it can see — a private one — and hands clients a relay candidate they cannot possibly reach.

The symptom is precise and confusing: allocations succeed, the relay candidate appears in the browser, and no media flows. The fix is to tell coturn both addresses:

# public/private — coturn listens on the private address
# but advertises the public one to clients.
external-ip=203.0.113.7/10.0.0.5

On a bare-metal or colocated server that genuinely holds its public address, the plain form is correct:

external-ip=203.0.113.7

If you are debugging a cloud TURN server that authenticates but does not relay, check this before anything else. It and the relay port range account for most of these cases between them.

Choosing a credential mechanism

coturn supports two, and they are alternatives rather than layers. Configuring both does not give you both.

Static users — for a lab only

lt-cred-mech
user=testuser:testpassword

Fine for verifying an install. Wrong for production, because a browser client has to ship the credential, which means every visitor can read it out of the network tab and relay their own traffic through your server at your expense. Relays get scanned for this.

Time-limited REST credentials — for production

use-auth-secret
static-auth-secret=REPLACE_WITH_A_LONG_RANDOM_STRING

Your backend derives credentials that expire; coturn validates the HMAC with no database lookup:

// Server side only. The secret must never reach a browser.
import crypto from 'node:crypto';

function turnCredentials(userId, secret, ttlSeconds = 3600) {
  const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
  const username = `${expiry}:${userId}`;
  const credential = crypto
    .createHmac('sha1', secret)
    .update(username)
    .digest('base64');
  return { username, credential, ttl: ttlSeconds };
}

The transition trips everyone once: enabling use-auth-secret stops your static user= entries from authenticating. That is the mechanism working as designed, not a broken install — but if you were testing with a static user and switched, your test will start failing for a reason that has nothing to do with what you were changing.

Set the TTL longer than your longest plausible call. Credentials are checked at allocation time, so a call in progress survives expiry — but an ICE restart mid-call re-allocates, and expired credentials then drop the call at exactly the moment the network hiccuped. An hour is a reasonable default; a few minutes will produce failures you cannot reproduce.

TLS, and the renewal that breaks TURNS in ninety days

Obtain a certificate for the TURN hostname:

sudo apt install certbot
sudo certbot certonly --standalone -d turn.example.com

Certbot installs its own renewal timer, so you do not need a cron entry. What you do need is the part almost every guide omits.

coturn reads its certificate at startup and does not notice a renewal. Ninety days after a successful install, certbot quietly renews the certificate on disk, coturn keeps serving the old one, and it expires. TURNS stops working — and only TURNS, so calls on unrestricted networks continue fine while your most locked-down users lose connectivity. Nothing in your logs will point at a certificate.

Fix it with a deploy hook that restarts coturn when the certificate actually changes:

# /etc/letsencrypt/renewal-hooks/deploy/coturn.sh
#!/bin/sh
systemctl restart coturn
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/coturn.sh
sudo certbot renew --dry-run   # confirms the hook runs

The second thing to check is file permissions. coturn drops privileges to the turnserver user, and /etc/letsencrypt/live and archive are root-only by default, so the process may be unable to read a certificate whose path is perfectly correct. Either grant group read access to those directories or copy the certificate to a location coturn owns as part of the deploy hook.

One deployment note worth knowing: on networks that permit only HTTPS egress, running TURNS on port 443 rather than 5349 is frequently the difference between connecting and not. That does mean coordinating with anything else on the host that wants 443.

Firewall

sudo ufw allow 3478/tcp
sudo ufw allow 3478/udp
sudo ufw allow 5349/tcp
sudo ufw allow 49160:49200/udp    # must match min-port/max-port
sudo ufw enable

On a cloud provider, this rule has to exist in the security group or network ACL as well as in the host firewall. Opening it in one place and not the other produces exactly the authenticates-but-does-not-relay symptom.

Starting it, and confirming it is really up

sudo systemctl enable --now coturn
systemctl status coturn
sudo ss -lunp | grep turnserver   # UDP listeners
sudo ss -ltnp | grep turnserver   # TCP listeners

What you should see is coturn bound on 3478 for both UDP and TCP, and on 5349 for TCP. If the service reports active but nothing is listening, revisit /etc/default/coturn.

Verifying that it relays

"Listening" and "relaying" are different claims, and only the second one matters. Two checks answer it.

From the server side

coturn ships a client that performs a real allocation:

turnutils_uclient -v -u USERNAME -w PASSWORD turn.example.com

Use credentials your backend generated if you are on the REST scheme — the HMAC has to match. Successful output shows an allocation and a relayed address; failure output names the reason, which is considerably more informative than what a browser gives you.

To inspect live sessions, use coturn's own CLI rather than the client tool. It listens on port 5766 by default and needs no-cli removed and cli-password set:

telnet 127.0.0.1 5766
> ps          # list active sessions

Leave the CLI disabled (no-cli) unless you are actively using it, and never expose 5766 publicly.

From a browser, on the network that matters

The server-side check confirms the server works from the server. It says nothing about whether a user on a corporate VPN can reach it — and that is the failure you are actually trying to prevent. The browser-side condition is a single one: a candidate of type relay appears.

const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'turn:turn.example.com:3478', username, credential }],
  // Force relay so a working direct path cannot mask a dead TURN server.
  iceTransportPolicy: 'relay',
});
pc.onicecandidate = ({ candidate }) => {
  if (candidate) console.log(candidate.type, candidate.protocol, candidate.address);
};
pc.createDataChannel('probe');
pc.createOffer().then((o) => pc.setLocalDescription(o));

iceTransportPolicy: 'relay' belongs in a test and never in production, where it forces every call through the relay and multiplies your bandwidth bill. The tester on this site runs this check in your browser — including from the restrictive network you want to prove works — and sends your credentials only to your own server.

Operating it

Log rotation

coturn is talkative. Without rotation the log fills the disk, and a full disk on a TURN server takes down every call on it:

# /etc/logrotate.d/coturn
/var/log/turnserver.log {
  daily
  rotate 14
  compress
  delaycompress
  notifempty
  missingok
  create 0640 turnserver turnserver
  postrotate
    systemctl reload coturn > /dev/null 2>&1 || true
  endscript
}

Leave verbose off in production. It is invaluable while debugging an allocation failure and expensive to leave running.

What to monitor

Recent coturn builds expose Prometheus metrics with --prometheus (port 9641 by default). The metrics worth alerting on:

  • Network throughput — the real capacity limit. A TURN server saturates its NIC long before its CPU, because it copies packets rather than decoding them.
  • Active allocations against your total-quota and against the size of your relay port range, since each allocation consumes a port.
  • Authentication failures — a spike means either a credential-generation bug or someone probing.
  • Certificate expiry — see the renewal section above. This one is worth an explicit check rather than trusting the automation.

Capacity planning is a bandwidth exercise. A relayed 1.5 Mbps video call moves roughly 1.35 GB through the server per hour counting both directions, and that number times your relayed call-hours times your provider's egress rate is your bill. TURN cost optimization works through it properly.

Kernel tuning, if you get there

Under heavy concurrency the default UDP buffers become the bottleneck:

# /etc/sysctl.d/99-coturn.conf
net.core.rmem_max=26214400
net.core.wmem_max=26214400
net.ipv4.udp_rmem_min=8192
net.ipv4.udp_wmem_min=8192
fs.file-max=2097152
sudo sysctl --system

This is a step for servers under real load, not for a fresh install. Measure first.

Troubleshooting, in the order that finds it fastest

SymptomCause to check first
Service "active", nothing listeningTURNSERVER_ENABLED in /etc/default/coturn
Allocation succeeds, no mediaRelay port range closed in host firewall or cloud security group
Relay candidate has a private addressexternal-ip missing the public/private form
401 on every allocationRealm mismatch, or static user left over after enabling use-auth-secret
Worked for months, turns: now failsCertificate renewed on disk; coturn never restarted
Certificate path correct, coturn cannot read itPermissions — coturn dropped privileges to turnserver
Allocations rejected under loadtotal-quota, or the relay port range is too small
turn: fails, turns: worksNot a server fault — the client's network blocks UDP

When the answer is not in that table, turn on verbose, reproduce once, and turn it back off. The allocation path logs enough to identify almost any remaining case. Broader triage across the whole stack is in debugging WebRTC connections, and the protocol-level background for what you will see is in the TURN guide.

Check Your Own Servers

Once your turnserver.conf is live, confirm it actually relays traffic and hands back relay candidates. Point the tester at your turn: and turns: URLs with your credentials to verify the deployment end to end.

Open the Free ICE Server Tester →