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

  1. Synapse (Matrix Homeserver): The central brain. It handles user accounts, rooms, and chat messages.
  2. LiveKit (The SFU): The heavy lifter. It receives video/audio streams from each participant and forwards them efficiently to others.
  3. CoTurn (The Relay): Essential for connectivity. If two users cannot connect directly (e.g., behind strict firewalls or mobile NATs), CoTurn relays the traffic.
  4. 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).
  5. Element Call (The Frontend): The specialized video conferencing UI. It can run standalone or embedded inside Element Web.
  6. 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: true is in livekit.yaml.
    • Ensure firewall allows UDP 50000-50200 (or your configured range) and TCP 7881.
    • Ensure CoTurn is actually running and turn_uris are correct in Synapse.

2. The Loopback SSL Error (Error 500)

  • Symptom: lk-jwt-service logs show Unable to create room or 500 errors.
  • Cause: lk-jwt tries to connect to wss://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_hosts in docker-compose.yml to point the domain livekit.yourdomain.com to 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-proxy creates 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_HOST and LETSENCRYPT_HOST set correctly. If using multiple exposed ports (like LiveKit), ensure VIRTUAL_PORT is 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.com link, 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.
  • Symptom: “Share Room” gives a matrix.to link 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:

  1. Remove Insecure Flags: Delete LIVEKIT_INSECURE_SKIP_VERIFY_TLS from lk-jwt once certificates are valid.
  2. CoTurn Auth: Ensure use-auth-secret is enabled in turnserver.conf. Never allow anonymous access to your relay.
  3. Firewall: Only expose necessary ports. 7880 (LiveKit API) should NOT be public; it should only be accessed via the nginx-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.

Updated: