- Python 49.4%
- Shell 48.2%
- Makefile 2.4%
| conf.d | ||
| overlay | ||
| plan | ||
| changelog | ||
| Makefile | ||
| README.md | ||
| README.rst | ||
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). Theexternal-ipparameter 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) |
| 10000–20000 | 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):
- The application and CoTURN share a shared secret
- The application generates a username in the format
EXPIRY_TIMESTAMP:IDENTIFIER - The password is
base64(HMAC-SHA1(secret, username)) - 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,
usernamefield) - Password: (output from the helper,
passwordfield)
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:
- The secret is never exposed to the client — only derived credentials are
- Each credential has a short TTL (1 hour by default)
- The username contains a timestamp — expired credentials are rejected
- 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 |