No description
  • Python 49.4%
  • Shell 48.2%
  • Makefile 2.4%
Find a file
2026-04-02 00:09:48 +00:00
conf.d Initiatin Coturn Server 2026-04-01 22:37:13 +00:00
overlay Initiatin Coturn Server 2026-04-01 22:37:13 +00:00
plan Initiatin Coturn Server 2026-04-01 22:37:13 +00:00
changelog Initiatin Coturn Server 2026-04-01 22:37:13 +00:00
Makefile Initiatin Coturn Server 2026-04-01 22:37:13 +00:00
README.md Fix English Readme 2026-04-02 00:09:48 +00:00
README.rst Initiatin Coturn Server 2026-04-01 22:37:13 +00:00

CoTURN - Shared TURN/STUN Server

TurnKey Linux appliance providing a centralized TURN/STUN server for NAT traversal in WebRTC applications. Designed to serve multiple applications (Jitsi, XMPP, BigBlueButton, Odoo, etc.) from a single shared instance.


1. Architecture Overview (IPv4 Only)

                       Internet
                          |
                    [Firewall/NAT]
                          |
             +------ internal network -----+
             |            |                |
         [Jitsi]    [BigBlueButton]      [XMPP]
             |            |                |
             +-----+------+--------+------+
                   |
              [CoTURN LXC]
              2804:710:d0:5::3478  (public IPv6)
              10.x.x.x             (NAT IPv4)

All services point to the same CoTURN instance. Each appliance receives the same shared secret and generates ephemeral credentials (HMAC-SHA1) independently, or consumes credentials generated by the helper script turnkey-coturn-credentials.


2. LXC Container

2.1 Creating from a built rootfs

# Create the container directory
mkdir -p /var/lib/lxc/coturn/rootfs

# Copy the rootfs
rsync -aHAX /turnkey/fab/products/coturn/build/root.sandbox/ \
    /var/lib/lxc/coturn/rootfs/

2.2 LXC configuration with macvlan bridge (public IPv6)

Create /var/lib/lxc/coturn/config:

lxc.uts.name = coturn
lxc.rootfs.path = dir:/var/lib/lxc/coturn/rootfs

# Network: macvlan bridge on the physical interface.
# This gives the container its own public IPv6 via SLAAC + static assignment.
lxc.net.0.type = macvlan
lxc.net.0.macvlan.mode = bridge
lxc.net.0.link = eth0
lxc.net.0.flags = up
lxc.net.0.ipv6.address = 2804:710:d0:5::3478/64
lxc.net.0.ipv6.gateway = 2804:710:d0:5::ffff

lxc.autodev = 1
lxc.tty.max = 4
lxc.pty.max = 1024
lxc.cap.drop = sys_module mac_admin mac_override sys_time
lxc.apparmor.profile = unconfined

Why macvlan bridge? The container gets its own MAC address on the host's physical interface. The router assigns a public IPv6 via SLAAC, and we additionally set a memorable static address (::3478). No NAT66 involved — the container is directly reachable from the Internet over IPv6.

2.3 Start and verify

lxc-start -n coturn
lxc-attach -n coturn -- ping6 -c 2 google.com
lxc-attach -n coturn -- systemctl status coturn

3. CoTURN Configuration

3.1 Firstboot

On the first interactive run, the appliance prompts for:

  • Realm — the public FQDN of the TURN server (e.g. turn.example.com)
  • External IP — the public IPv4 of the NAT gateway (if the container sits behind IPv4 NAT)

To configure non-interactively:

lxc-attach -n coturn -- /usr/lib/inithooks/bin/coturn.py \
    --realm=turn.example.com \
    --external-ip=203.0.113.50

3.2 The /etc/turnserver.conf file

After firstboot, conf.d/main generates a clean configuration:

# Ports
listening-port=3478
tls-listening-port=5349
min-port=10000
max-port=20000

# HMAC authentication (ephemeral credentials)
use-auth-secret
static-auth-secret=<AUTO_GENERATED_SECRET>

# Realm
realm=turn.example.com

# NAT: public IPv4 mapped to the container's private IP.
# Required when the container is behind IPv4 NAT.
external-ip=203.0.113.50/10.88.5.X

# Security
no-multicast-peers
stale-nonce=600
fingerprint
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=::1

3.3 The external-ip parameter (IPv4 NAT)

This is the most critical setting for operating behind NAT:

# Syntax: external-ip=<PUBLIC_IP>/<PRIVATE_IP>
external-ip=203.0.113.50/10.88.5.123

CoTURN advertises external-ip as the relay address in TURN responses. Without it, clients receive the private IP and the connection fails.

IPv6 does not need external-ip — the container already has a real public IPv6 address (2804:710:d0:5::3478). The external-ip parameter is only needed for IPv4 NAT scenarios.

3.4 Firewall / router ports to open

Port Protocol Direction Purpose
3478 UDP + TCP inbound STUN/TURN
5349 TCP inbound TURN over TLS (TURNS)
1000020000 UDP inbound Media relay
443 TCP inbound TURN over TLS on port 443 (optional)

All these ports must be port-forwarded (DNAT) from the public IPv4 address to the container's private IP.


4. Ephemeral Credentials (HMAC-SHA1)

4.1 How it works

CoTURN uses the TURN REST API mechanism (draft-uberti-behave-turn-rest-00):

  1. The application and CoTURN share a shared secret
  2. The application generates a username in the format EXPIRY_TIMESTAMP:IDENTIFIER
  3. The password is base64(HMAC-SHA1(secret, username))
  4. CoTURN validates the HMAC and rejects expired credentials

No direct communication between the application and CoTURN is needed to generate credentials — sharing the same secret is sufficient.

4.2 Retrieve the shared secret

lxc-attach -n coturn -- grep '^static-auth-secret=' /etc/turnserver.conf

The secret is auto-generated at firstboot. To regenerate it manually:

lxc-attach -n coturn -- /usr/lib/inithooks/firstboot.d/20regen-coturn-secrets

4.3 Generate credentials with the helper

lxc-attach -n coturn -- turnkey-coturn-credentials myuser 3600

Output (JSON ready for WebRTC ICE server configuration):

{
  "username": "1743555600:myuser",
  "password": "K3bV8x9...",
  "ttl": 3600,
  "uris": [
    "turn:turn.example.com:3478?transport=udp",
    "turn:turn.example.com:3478?transport=tcp",
    "turns:turn.example.com:5349?transport=tcp"
  ]
}

4.4 Generate credentials in any language

The algorithm is straightforward. Any application can generate credentials locally:

Python:

import time, hmac, hashlib, base64

def turn_credentials(secret, user="webrtc", ttl=3600):
    expiry = int(time.time()) + ttl
    username = f"{expiry}:{user}"
    password = base64.b64encode(
        hmac.new(secret.encode(), username.encode(), hashlib.sha1).digest()
    ).decode()
    return {"username": username, "password": password, "ttl": ttl}

Bash:

SECRET="the_shared_secret"
EXPIRY=$(($(date +%s) + 3600))
USERNAME="${EXPIRY}:myuser"
PASSWORD=$(echo -n "$USERNAME" | openssl dgst -sha1 -hmac "$SECRET" -binary | base64)

5. Per-application Configuration

In all examples below, replace:

Variable Value
TURN_SECRET the static-auth-secret from /etc/turnserver.conf
turn.example.com the realm/FQDN of your TURN server

5.1 Jitsi Meet

Jitsi uses Prosody (JVB + Prosody). Configuration spans two files:

/etc/jitsi/meet/yourdomain-config.js (client config):

config.p2p.stunServers = [
    { urls: 'stun:turn.example.com:3478' }
];
// Client-side ICE uses credentials injected by Prosody/mod_turncredentials,
// not configured here directly.

/etc/jitsi/videobridge/sip-communicator.properties (JVB):

org.ice4j.ice.harvest.STUN_MAPPING_HARVESTER_ADDRESSES=turn.example.com:3478

/etc/jitsi/videobridge/jvb.conf (JVB ICE with TURN):

videobridge {
    ice {
        tcp {
            enabled = false
        }
        udp {
            port = 10000
        }
    }
}

/etc/prosody/conf.d/yourdomain.cfg.lua (Prosody — Jitsi's XMPP server):

turncredentials_secret = "TURN_SECRET";
turncredentials_port = 3478;
turncredentials_ttl = 3600;
turncredentials_host = "turn.example.com";
-- Install mod_turncredentials:
-- apt install prosody-modules
-- Add "turncredentials" to modules_enabled

The mod_turncredentials Prosody module generates HMAC-SHA1 credentials and delivers them to Jitsi clients via XMPP (XEP-0215). Same shared secret, same algorithm.

5.2 BigBlueButton

BBB configures TURN in an XML file:

/usr/share/bbb-web/WEB-INF/classes/spring/turn-stun-servers.xml:

<bean id="weightedTurnServer" class="org.bigbluebutton.web.services.turn.WeightedTurnServer">
    <constructor-arg index="0" value="turn:turn.example.com:3478"/>
    <constructor-arg index="1" value="86400"/>
    <constructor-arg index="2" value="TURN_SECRET"/>
    <constructor-arg index="3" value="1"/>
</bean>
<bean id="weightedStunServer" class="org.bigbluebutton.web.services.turn.WeightedStunServer">
    <constructor-arg index="0" value="stun:turn.example.com:3478"/>
    <constructor-arg index="1" value="1"/>
</bean>

Or, on newer versions (BBB 2.6+), via /etc/bigbluebutton/bbb-web.properties:

stun.server=stun:turn.example.com:3478
turn.server=turn:turn.example.com:3478
turn.secret=TURN_SECRET
turn.ttl=86400

bbb-web generates HMAC credentials internally and distributes them to clients via the API. Only the secret needs to be configured.

5.3 Ejabberd (XMPP)

/etc/ejabberd/ejabberd.yml:

listen:
  -
    port: 3478
    # Disable the built-in TURN listener if using external coturn
    # (remove this entire section)

# Configure external TURN for WebRTC (via mod_stun_disco)
modules:
  mod_stun_disco:
    credentials_lifetime: 3600
    secret: "TURN_SECRET"
    services:
      -
        host: turn.example.com
        port: 3478
        type: turn
        transport: udp
        restricted: true
      -
        host: turn.example.com
        port: 3478
        type: turn
        transport: tcp
        restricted: true
      -
        host: turn.example.com
        port: 5349
        type: turns
        transport: tcp
        restricted: true
      -
        host: turn.example.com
        port: 3478
        type: stun
        transport: udp

mod_stun_disco generates HMAC-SHA1 credentials using the same TURN REST API algorithm and delivers them to XMPP clients via XEP-0215.

Important: disable ejabberd's built-in TURN server (remove the listener on port 3478) to avoid conflicts.

5.4 Prosody (XMPP)

/etc/prosody/prosody.cfg.lua:

modules_enabled = {
    -- ... other modules ...
    "turncredentials";
}
turncredentials_secret = "TURN_SECRET";
turncredentials_port = 3478;
turncredentials_ttl = 3600;
turncredentials_host = "turn.example.com";

mod_turncredentials is included in the prosody-modules package.

5.5 Nextcloud Talk

Administration > Talk > TURN servers:

Field Value
Scheme turn: and turns:
Server turn.example.com:3478 and turn.example.com:5349
Secret TURN_SECRET
Protocol UDP, TCP

Nextcloud Talk generates HMAC credentials internally using the secret provided in the admin interface.

5.6 Odoo (Mail/Discuss with WebRTC)

In Odoo 17+, TURN is configured via system parameters:

Settings > Technical > System Parameters:

Key Value
mail.turn_server_url turn:turn.example.com:3478
mail.turn_server_secret TURN_SECRET
mail.turn_server_ttl 3600

Odoo generates HMAC credentials internally. Older versions may require a custom module or static ICE server configuration.

5.7 Synapse (Matrix)

/etc/matrix-synapse/homeserver.yaml:

turn_uris:
  - "turn:turn.example.com:3478?transport=udp"
  - "turn:turn.example.com:3478?transport=tcp"
  - "turns:turn.example.com:5349?transport=tcp"
turn_shared_secret: "TURN_SECRET"
turn_user_lifetime: 3600000    # milliseconds (1 hour)
turn_allow_guests: false

5.8 Any generic WebRTC application

If the application allows direct ICE server configuration:

const iceServers = [{
    urls: [
        "turn:turn.example.com:3478?transport=udp",
        "turn:turn.example.com:3478?transport=tcp",
        "turns:turn.example.com:5349?transport=tcp"
    ],
    username: credentials.username,   // "TIMESTAMP:user"
    credential: credentials.password  // HMAC-SHA1
}];
const pc = new RTCPeerConnection({ iceServers });

Credentials must be generated on the backend using the shared secret (see section 4.4) and delivered to the frontend via your application's API.


6. Validation and Testing

6.1 Basic STUN test

# From the host or any machine with IPv6 connectivity
turnutils_stunclient turn.example.com

6.2 TURN test with authentication

# Generate credentials
CREDS=$(lxc-attach -n coturn -- turnkey-coturn-credentials test 600)
USER=$(echo "$CREDS" | jq -r .username)
PASS=$(echo "$CREDS" | jq -r .password)

# Test TURN relay
turnutils_uclient -u "$USER" -w "$PASS" -e turn.example.com -p 3478 turn.example.com

6.3 Browser-based test

Open https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ and enter:

  • STUN or TURN URI: turn:turn.example.com:3478
  • Username: (output from the helper, username field)
  • Password: (output from the helper, password field)

Click "Gather candidates" — you should see candidates of type relay.

6.4 Check logs

lxc-attach -n coturn -- tail -f /var/log/coturn/turnserver.log

7. TLS (TURNS on port 5349)

For TURNS to work, a valid TLS certificate is required:

# Via Let's Encrypt (requires DNS pointing to the server)
lxc-attach -n coturn -- certbot certonly --standalone \
    -d turn.example.com \
    --agree-tos -m admin@example.com

# Configure in turnserver.conf
lxc-attach -n coturn -- bash -c 'cat >> /etc/turnserver.conf <<EOF
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
EOF'

lxc-attach -n coturn -- systemctl restart coturn

TURNS is essential for clients on corporate networks that block UDP and only allow HTTPS (port 443). To also listen on port 443:

# In turnserver.conf
alt-tls-listening-port=443

8. Multiple Services, One Shared Secret

All applications configured above use the same shared secret. This is secure because:

  1. The secret is never exposed to the client — only derived credentials are
  2. Each credential has a short TTL (1 hour by default)
  3. The username contains a timestamp — expired credentials are rejected
  4. The HMAC guarantees credentials cannot be forged without the secret

It is neither necessary nor possible to create per-application secrets when using use-auth-secret in coturn. If you need per-tenant isolation, use separate realms with distinct secrets backed by an external database (PostgreSQL/MySQL/Redis).


9. Deployment Checklist

[ ] DNS: turn.example.com -> public IPv4 + AAAA for IPv6
[ ] Firewall: ports 3478 UDP/TCP, 5349 TCP, 10000-20000 UDP open
[ ] NAT: port-forward the above ports to the container's private IP
[ ] external-ip configured in turnserver.conf (only if behind IPv4 NAT)
[ ] realm set to the correct FQDN
[ ] TLS configured for TURNS (Let's Encrypt certificate)
[ ] Shared secret copied to all applications
[ ] STUN test returns correct reflexive address
[ ] TURN test returns relay candidates
[ ] End-to-end test: WebRTC call works with TURN forced

10. Troubleshooting

Symptom Likely cause Solution
No relay candidates Firewall blocking port 3478 Open UDP/TCP ports
Relay returns private IP external-ip not configured Set external-ip=PUBLIC/PRIVATE
401 Unauthorized Wrong secret or expired credential Verify secret and TTL
TURNS not working Missing or invalid certificate Configure cert/pkey with Let's Encrypt
Works on IPv6 but not IPv4 Missing port-forward on NAT Configure DNAT on the router
Calls drop after 5 minutes Nonce expiry (stale-nonce) Normal — client re-authenticates automatically