Configuring TURN with TLS on Synapse with Eturnal
This is a post I was not expecting to make, but after I mentioned in an online community about my Matrix setup for video calls with my family, there was some interest in it, and so I've decided to document it here as it may help others to achieve the same.
What are STUN and TURN?
tl;dr: STUN and TURN are workarounds against the long-term damage caused to the internet (IPv4 NAT) by the horrible horrible people who refuse to learn IPv6.
STUN is, as far as I can tell, not used by Matrix/Synapse, so I won't talk much about it, but since it comes "for free" with Eturnal, I thought I'd briefly mention what it does. In a nutshell, STUN helps two parties that are behind isolated NAT networks to know each other's public IP addresses and outgoing ports, which is useful for cool stuff like UDP hole punching.
TURN on the other hand is what saves the day when a direct connection cannot be established: it sits on the public internet and in between the two parties, relaying packets from one to the other. Matrix uses this TURN server to relay the audio and video to participants during a call.
The purpose of this post is to setup a TURN server using Eturnal and configure Synapse to use it.
Assumptions for this guide
- You already have a working Matrix server running Synapse.
- You have a small VPS or some other publicly-accessible box where the TURN server can be deployed.*
- You have at least a minimal understanding of what you're getting yourself into here (Docker, networking, web technologies, CLI, etc).
Architecture diagram

Obtaining TLS certificates and automating the renewal
First, let's create the directory structure used by both containers and assign the necessary permissions. By default, Eturnal runs with UID 9000
, so we need to adjust the permissions so that Let's Encrypt (which will also be running with UID 9000
) can work on these directories.
$ cd ~
$ mkdir -p eturnal/letsencrypt
$ cd eturnal/letsencrypt
$ mkdir config logs work
$ cd ..
$ sudo chown -Rv 9000:9000 letsencrypt
Next, we can invoke certbot
manually (only needed once!) to obtain the certificate:
cloudflare_creds.ini
which contains my Cloudflare API key. This is because I'm using the DNS challenge method with the Cloudflare plugin. Your requirements may vary, and with other methods these lines may not be required, so consult certbot's documentation for your particular case.$ docker run --rm \
--name "certbot" \
--user 9000:9000 \
-v "./cloudflare_creds.ini:/cloudflare_creds.ini" \
-v "./letsencrypt:/letsencrypt" \
certbot/dns-cloudflare \
certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /cloudflare_creds.ini \
--non-interactive \
--agree-tos \
--config-dir /letsencrypt/config \
--work-dir /letsencrypt/work \
--logs-dir /letsencrypt/logs \
-d "turn.example.com"
Once the request succeeds, we can create a script which will handle renewing the certificate:
#! /bin/bash
cd "$(dirname "$0")"
docker run --rm \
--name certbot \
--user 9000:9000 \
-v "~/eturnal/cloudflare_creds.ini:/cloudflare_creds.ini" \
-v "~/eturnal/letsencrypt:/letsencrypt" \
certbot/dns-cloudflare \
renew \
--dns-cloudflare \
--non-interactive \
--agree-tos \
--config-dir /letsencrypt/config \
--work-dir /letsencrypt/work \
--logs-dir /letsencrypt/logs \
--dns-cloudflare-credentials /cloudflare_creds.ini
docker container restart eturnal
~/eturnal/renew_certificate.sh
And then set it to run once a day, preferably when everyone is asleep and no calls are taking place:
$ crontab -e
0 4 * * * $HOME/eturnal/renew-certificate.sh
Runs the script every day at 4 am
Configuring Eturnal
Create the following files, replacing the values between angle brackets below and every occurrence of turn.example.com
with your own domain:
---
services:
eturnal:
container_name: eturnal
image: ghcr.io/processone/eturnal:latest
environment:
ETURNAL_RELAY_MIN_PORT: 49160
ETURNAL_RELAY_MAX_PORT: 59160
ETURNAL_RELAY_IPV4_ADDR: <YOUR_VPS_IPV4_ADDRESS>
ETURNAL_RELAY_IPV6_ADDR: <YOUR_VPS_IPV6_ADDRESS>
ETURNAL_SECRET: <VERY_BIG_RANDOM_STRING> #You will need this value when configuring Synapse
### networking options
volumes:
- ./eturnal.yml:/etc/eturnal.yml:ro
- ./letsencrypt/config:/etc/letsencrypt:ro
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
network_mode: host
~/eturnal/docker-compose.yml
eturnal:
listen:
-
ip: "::" # This is the default.
port: 3478 # This is the default for "transport: udp".
transport: udp # This is the default.
enable_turn: true # This is the default.
-
ip: "::" # This is the default.
port: 3478 # This is the default for "transport: auto".
transport: auto # Default: "udp".
enable_turn: true # This is the default.
-
ip: "::" # This is the default.
port: 5349 # This is the default for "transport: tls".
transport: tls # Default: "udp".
enable_turn: true # This is the default.
realm: turn.example.com
tls_crt_file: /etc/letsencrypt/live/turn.example.com/fullchain.pem
tls_key_file: /etc/letsencrypt/live/turn.exmaple.com/privkey.pem
tls_options:
- no_tlsv1
- no_tlsv1_1
- cipher_server_preference
~/eturnal/eturnal.yml
With all of this in place, run $ docker compose up -d
.
Configuring Synapse
Edit your Synapse's homeserver.yaml
file and add the following config (again, replacing the domain and the secret) then restart the service.
turn_uris: [ "turns:turn.example.com?transport=tcp" ]
turn_shared_secret: "<SAME_VERY_BIG_RANDOM_STRING_FROM_ETURNAL_COMPOSE_FILE>"
turn_user_lifetime: 86400000
turn_allow_guests: True
Testing your setup
If all went well, using the legacy Element client (not Element X) you should be able to establish video calls with contacts also using Element or Element Desktop (where these will appear as "legacy calls").