How to set up Video/Voice calling with Matrix
A blog written with LLM assistance. I gave it my information and the process I went through with the terminal to make the writing of the actual blog easier, it should however be accurate despite being mainly written by an LLM (Gemini 3 Pro), I just want to be forward and honest about any of my posts that do contain LLM assistance in the creation of it. I made it after setting up Matrix video calling on top of my existing hosted Synapse + Element stack. If you run into any issues with this feel free to poke me directly about this, but I do think this should include most of the issues I came across and how to solve them.
It should also be noted that some parts of this assume youâre using Jwildersâ Nginx-proxy/Dockergen as part of your frontend stack like I currently am (even though I might move to Caddy later). Just beware as any âVIRTUAL_HOSTâ and âLETSENCRYPT_HOSTâ and alike variables (like âVIRTUAL_POSTâ) are actually specific to that setup, and you will not need them elsewhere.
Without further ado; Hereâs the rest of the article:
1. The Architecture
Gone are the days of peer-to-peer mesh networks (which consume massive bandwidth) or Jitsi integration (which feels external). The modern Matrix stack uses Native MatrixRTC, powered by an SFU (Selective Forwarding Unit).
The Components
- Synapse (Matrix Homeserver): The central brain. It handles user accounts, rooms, and chat messages.
- LiveKit (The SFU): The heavy lifter. It receives video/audio streams from each participant and forwards them efficiently to others.
- CoTurn (The Relay): Essential for connectivity. If two users cannot connect directly (e.g., behind strict firewalls or mobile NATs), CoTurn relays the traffic.
- lk-jwt-service (The Bridge): Synapse doesnât speak âLiveKitâ natively yet. This service sits in the middle, authenticating Matrix users and issuing LiveKit access tokens (JWTs).
- Element Call (The Frontend): The specialized video conferencing UI. It can run standalone or embedded inside Element Web.
- Nginx Proxy & ACME: Handles SSL termination and routing for all the above services.
2. The Setup (Docker Compose)
We assume a standard nginx-proxy setup is already running on a network named frontendweb.
A. LiveKit Server (The Media Engine)
LiveKit requires specific port ranges for UDP (media) and TCP (signaling).
docker-compose.yml:
services:
livekit:
image: livekit/livekit-server
command: --config /etc/livekit.yaml
restart: unless-stopped
ports:
- "7881:7881" # TCP Signaling
- "7882:7882/udp" # Initial UDP
- "50000-50200:50000-50200/udp" # Media range
expose:
- 7880 # API Port (Internal only, behind proxy)
volumes:
- ./livekit.yaml:/etc/livekit.yaml
environment:
- VIRTUAL_HOST=livekit.yourdomain.com
- LETSENCRYPT_HOST=livekit.yourdomain.com
- VIRTUAL_PORT=7880
- LIVEKIT_KEYS=APIKey:SecretKey
depends_on:
- redis
redis:
image: redis:alpine
command: redis-server --save 60 1 --loglevel warning
volumes:
- ./redis_data:/data
networks:
default:
external:
name: frontendweb
livekit.yaml (Key settings):
port: 7880
rtc:
udp_port: 7882
tcp_port: 7881
port_range_start: 50000
port_range_end: 50200
use_external_ip: true # CRITICAL for Docker
turn_servers:
- host: turn.yourdomain.com
port: 3478
protocol: udp
secret: YOUR_TURN_SECRET
B. CoTurn (The Relay)
For maximum compatibility, run CoTurn in network_mode: host. This avoids Docker NAT issues with UDP.
docker-compose.yml:
services:
coturn:
image: coturn/coturn
network_mode: host
volumes:
- ./turnserver.conf:/etc/coturn/turnserver.conf:ro
- ./certs:/etc/coturn/certs:ro
turnserver.conf:
listening-port=3478
tls-listening-port=5349
min-port=49152
max-port=65535
external-ip=YOUR.PUBLIC.IP.ADDRESS
realm=turn.yourdomain.com
fingerprint
lt-cred-mech
use-auth-secret
static-auth-secret=YOUR_TURN_SECRET
no-multicast-peers
no-cli
C. The Bridge (lk-jwt-service)
This service needs to talk to LiveKit (to create rooms) and Synapse (to verify users).
docker-compose.yml:
services:
lk-jwt-service:
image: ghcr.io/element-hq/lk-jwt-service:latest
environment:
- LIVEKIT_URL=wss://livekit.yourdomain.com
- LIVEKIT_KEY=APIKey
- LIVEKIT_SECRET=SecretKey
- VIRTUAL_HOST=lk-jwt.yourdomain.com
- LETSENCRYPT_HOST=lk-jwt.yourdomain.com
- VIRTUAL_PORT=8080
# CRITICAL: Allow the container to loop back to the public domain via the proxy
extra_hosts:
- "livekit.yourdomain.com:INTERNAL_IP_OF_NGINX_PROXY"
D. Element Call (The Frontend)
A static React app served by Nginx.
docker-compose.yml:
services:
element-call:
image: ghcr.io/element-hq/element-call:latest
environment:
- VIRTUAL_HOST=elementcall.yourdomain.com
- LETSENCRYPT_HOST=elementcall.yourdomain.com
- VIRTUAL_PORT=8080
volumes:
- ./config.json:/app/config.json
config.json:
{
"default_server_config": {
"m.homeserver": { "base_url": "https://matrix.yourdomain.com", "server_name": "yourdomain.com" }
},
"livekit": { "livekit_service_url": "https://lk-jwt.yourdomain.com" },
"features": { "feature_group_calls": true, "feature_spa": true }
}
3. Connecting the Dots (Synapse & Discovery)
To make Element Web use this stack, we need to configure Synapse and the .well-known discovery file.
Synapse (homeserver.yaml)
Enable experimental features and point to the TURN server.
experimental_features:
msc3266_enabled: true # Room Summary
msc4222_enabled: true # State after
msc4140_enabled: true # Delayed events
turn_uris:
- "turn:turn.yourdomain.com:3478?transport=udp"
- "turn:turn.yourdomain.com:3478?transport=tcp"
turn_shared_secret: "YOUR_TURN_SECRET"
allow_guest_access: true # If you want link-sharing for non-users
Well-Known Discovery (.well-known/matrix/client)
This JSON tells clients where to find the call services.
{
"m.homeserver": { "base_url": "https://matrix.yourdomain.com" },
"org.matrix.msc3881": { "enabled": true, "url": "https://lk-jwt.yourdomain.com" },
"org.matrix.msc4143.rtc_foci": [
{ "type": "livekit", "livekit_service_url": "https://lk-jwt.yourdomain.com" }
]
}
4. Caveats, Gotchas & Troubleshooting
This is the hard-earned wisdom from the deployment process.
1. The âWaiting for Mediaâ / Connectivity Loop
- Symptom: You connect, but see spinning circles or âWaiting for mediaâ.
- Cause: LiveKit isnât advertising the correct IP, or UDP ports are blocked.
- Fix:
- Ensure
use_external_ip: trueis inlivekit.yaml. - Ensure firewall allows UDP 50000-50200 (or your configured range) and TCP 7881.
- Ensure CoTurn is actually running and
turn_urisare correct in Synapse.
- Ensure
2. The Loopback SSL Error (Error 500)
- Symptom:
lk-jwt-servicelogs showUnable to create roomor 500 errors. - Cause:
lk-jwttries to connect towss://livekit.yourdomain.com. Inside Docker, this domain resolves to the public IP. The request goes out to the firewall and tries to hairpin back in, which often fails or hits the proxy port without the correct headers. - Fix: Use
extra_hostsindocker-compose.ymlto point the domainlivekit.yourdomain.comto the Internal IP of the Nginx Proxy (e.g.,172.22.0.23). This keeps traffic internal but preserves the Host header for SSL termination.
3. The âSelf-Signed Certâ / ACME 404
- Symptom: Browser shows a scary security warning. Nginx returns 500 on HTTPS.
- Cause:
nginx-proxycreates a fallback 500 config if it canât find a valid cert. If ACME challenge fails (due to routing), you never get the cert. Catch-22. - Fix: Ensure the service exposing port 80/443 has
VIRTUAL_HOSTandLETSENCRYPT_HOSTset correctly. If using multiple exposed ports (like LiveKit), ensureVIRTUAL_PORTis set explicitly to the API port (e.g., 7880).
4. Guest Access (âRegistration Disabledâ)
- Symptom: A guest clicks a call link and gets â403: Registration has been disabledâ.
- Cause: The standalone Element Call app tries to register a real user account if not explicitly in âSPA/Guestâ mode, or if the homeserver forbids it.
- Workaround: Instead of sharing the
https://elementcall.yourdomain.comlink, share the Element Web room link (https://element.yourdomain.com/#/room/#room:alias). Element Web handles guest registration much more gracefully than the standalone call app currently does.
5. Matrix.to Links
- Symptom: âShare Roomâ gives a
matrix.tolink which might confuse users. - Fix: In Element Webâs
config.json, set"permalink_prefix": "https://element.yourdomain.com".
5. Security Hardening
Once it works, lock it down:
- Remove Insecure Flags: Delete
LIVEKIT_INSECURE_SKIP_VERIFY_TLSfromlk-jwtonce certificates are valid. - CoTurn Auth: Ensure
use-auth-secretis enabled inturnserver.conf. Never allow anonymous access to your relay. - Firewall: Only expose necessary ports.
7880(LiveKit API) should NOT be public; it should only be accessed via thenginx-proxy.
6. Future Proofing
This stack is currently composed of âExperimentalâ features (MSCs). While stable enough for daily use, keep an eye on Synapse updates, as MSC implementations can change or be finalized, requiring config updates.