Self-Hosting a STUN/TURN Server with coturn on Linux: Solving NAT Traversal for WebRTC and VoIP

Networking tutorial - IT technology blog
Networking tutorial - IT technology blog

Context & Why: Why Direct Peer Connections Fail in the Real World

On paper, real-time communications apps like WebRTC video calls and SIP-based VoIP rely on a simple architecture: peer-to-peer (P2P). User A streams UDP packets directly to User B. Latency stays low, server compute remains negligible, and bandwidth costs hover near zero.

Production environments ruin this clean design immediately. Most devices connect behind home routers, carrier-grade NAT (CGNAT) on cellular networks, or corporate firewalls. Your laptop only knows an address like 192.168.1.105 or 10.0.0.15. To the public internet, that internal IP is completely unroutable.

The NAT Traversal Challenge: STUN vs. TURN

WebRTC and VoIP rely on the Interactive Connectivity Establishment (ICE) framework (RFC 8445) to discover a working path between peers. ICE coordinates two critical server roles:

  • STUN (Session Traversal Utilities for NAT): STUN functions as a mirror. A client sends a packet to the STUN server, which responds with the client’s public-facing IP and mapped port (for example, 203.0.113.45:49152). Under Full-Cone, Restricted Cone, or Port-Restricted NAT, both endpoints can exchange these reflexive candidates via signaling and establish direct P2P media flow. Because STUN only handles handshake discovery, one $5/month VPS can service millions of STUN requests without breaking a sweat.
  • TURN (Traversal Using Relays around NAT): Around 10% to 15% of real-world connections encounter Symmetric NAT—ubiquitous across 4G/5G mobile carriers and enterprise Wi-Fi. Under Symmetric NAT, the router assigns a unique external port for every new remote endpoint IP. Direct UDP hole-punching fails completely. TURN steps in as an authenticated relay proxy: both peers route their encrypted SRTP packets directly through your TURN server, which forwards the payload back and forth.

Public STUN endpoints like Google’s stun.l.google.com:19302 work fine for testing. Free public TURN servers, however, simply do not exist. Relaying 720p video at 1.5 Mbps across thousands of calls generates massive bandwidth bills. If your application targets call completion rates north of 99%, running a dedicated TURN relay is mandatory. In the Linux ecosystem, coturn remains the gold standard.

Installation: Setting Up coturn on Ubuntu and Debian

Before launching your instance, confirm two infrastructure prerequisites:

  • A dedicated public IPv4 address. Direct network binding is best; if your VPS sits behind a 1:1 NAT provider like AWS EC2, you will need to map your private IP to your elastic public IP.
  • An A record pointing to the host (such as turn.yourdomain.com).

1. Install coturn and Enable the Daemon

Refresh package indexes and install the coturn package:

sudo apt update
sudo apt install coturn -y

Debian-based distributions leave coturn disabled out of the box. Toggle the startup flag in /etc/default/coturn:

sudo sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn

2. Generate a Shared Authentication Secret

Avoid hardcoding static usernames and passwords in production. Modern WebRTC architectures use the TURN REST API standard (RFC 5766 / draft-uberti-behave-turn-rest) to issue temporary credentials. Generate a 32-byte cryptographic secret using OpenSSL:

openssl rand -hex 32

Copy the output—a 64-character hex string. Both coturn and your backend auth service will share this secret.

Configuration: Hardening turnserver.conf

The default configuration file spans hundreds of commented options. Back it up so you have a clean slate:

sudo mv /etc/turnserver.conf /etc/turnserver.conf.bak

Create a fresh configuration file at /etc/turnserver.conf:

sudo nano /etc/turnserver.conf

Paste the configuration below. Replace YOUR_PUBLIC_IP, turn.yourdomain.com, and YOUR_STATIC_AUTH_SECRET with your values. If your server sits behind 1:1 NAT (like an AWS EC2 instance with an internal IP like 172.31.10.5), format the external-ip line as external-ip=YOUR_PUBLIC_IP/172.31.10.5.

# Standard listening ports
listening-port=3478
tls-listening-port=5349

# IP bindings
listening-ip=0.0.0.0
external-ip=YOUR_PUBLIC_IP

# Domain & Realm
realm=turn.yourdomain.com
server-name=turn.yourdomain.com

# Dynamic REST API credentials
use-auth-secret
static-auth-secret=YOUR_STATIC_AUTH_SECRET

# Relay port range (UDP)
min-port=49152
max-port=65535

# Packet validation for WebRTC ICE
fingerprint

# Security and abuse hardening
no-cli
no-multicast-peers
no-loopback-peers
stale-nonce=600

# Optional: TLS certificates for TURNS (port 5349)
# cert=/etc/letsencrypt/live/turn.yourdomain.com/fullchain.pem
# pkey=/etc/letsencrypt/live/turn.yourdomain.com/privkey.pem

# Logging
log-file=/var/log/turnserver/turnserver.log
simple-log
verbose

We restrict loopback and multicast relays here to stop external clients from probing internal VPC hosts through our relay.

3. Configure Firewall Rules

TURN relays need standard control ports and a wide UDP range for relayed media streams. In UFW, open the ports with these commands:

# Signaling & discovery
sudo ufw allow 3478/tcp
sudo ufw allow 3478/udp
sudo ufw allow 5349/tcp
sudo ufw allow 5349/udp

# High-port media relay range
sudo ufw allow 49152:65535/udp

# Apply rules
sudo ufw reload

On AWS, Google Cloud, or Azure, update your Security Groups or Virtual Network rules to match these ports. Overlooking the cloud firewall while configuring UFW is the most common reason relay tests fail.

4. Generate Ephemeral Credentials on Your Backend

Clients should never see your master secret. Instead, your API issues short-lived tokens (typically valid for 12 to 24 hours). The username packs a UNIX expiry timestamp with an identifier, and the password is an HMAC-SHA1 signature of that username. Here is a production-ready Python helper:

import base64
import hashlib
import hmac
import time

def get_turn_credentials(secret: str, username_prefix: str, ttl_seconds: int = 86400):
    # Generate expiry timestamp (e.g., current time + 24 hours)
    expiry_time = int(time.time()) + ttl_seconds
    username = f"{expiry_time}:{username_prefix}"
    
    # Calculate HMAC-SHA1 digest using the shared secret
    digest = hmac.new(
        secret.encode('utf-8'),
        username.encode('utf-8'),
        hashlib.sha1
    ).digest()
    
    password = base64.b64encode(digest).decode('utf-8')
    
    return {
        "iceServers": [
            {"urls": "stun:turn.yourdomain.com:3478"},
            {
                "urls": [
                    "turn:turn.yourdomain.com:3478?transport=udp",
                    "turn:turn.yourdomain.com:3478?transport=tcp"
                ],
                "username": username,
                "credential": password
            }
        ]
    }

Verification: Testing Your Server End-to-End

Restart the coturn systemd unit to load your configuration:

sudo systemctl restart coturn
sudo systemctl status coturn

1. Check Active Listening Sockets

Confirm the daemon is listening with ss:

sudo ss -ulpn | grep turnserver

You should see turnserver bound to 0.0.0.0:3478 and 0.0.0.0:5349.

2. Validate Candidates via Trickle ICE

Do not guess whether your setup works—test it against the browser’s native WebRTC stack:

  1. Navigate to the official WebRTC Trickle ICE test utility.
  2. Clear out the default Google STUN server entry.
  3. Add your STUN URI: stun:turn.yourdomain.com:3478 and click Gather candidates. A candidate of type srflx (Server Reflexive) should appear, showing your client’s public IP.
  4. Run your Python script to generate a fresh token pair. Add your TURN URI: turn:turn.yourdomain.com:3478?transport=udp, along with the temporary username and password.
  5. Click Gather candidates again. Watch the candidate list. Within 2 to 3 seconds, you must see an entry with type relay. That confirms coturn verified the HMAC signature and opened a media port in your 49152-65535 range.

3. Monitor Active Relay Traffic

When calls drop or audio fails, check the coturn log for authentication and binding events:

sudo tail -f /var/log/turnserver/turnserver.log

A healthy allocation generates logs similar to this:

session 000000000000000001: new, realm <turn.yourdomain.com>, username <1760000000:user1>
session 000000000000000001: IPv4. UDP allocate request received
session 000000000000000001: allocation created, relay addr 198.51.100.12:51234

Encountering repeated 401 Unauthorized errors? Check that your backend’s HMAC secret matches static-auth-secret down to the last character. Also ensure your server runs NTP via chrony or systemd-timesyncd; clock drift between your API server and coturn will reject valid timestamps before calls even connect.

Share: