What you will set up
Matrix apps make calls in one of two ways:
- Element Call. Today's Element apps use it for every call, one-to-one and in groups: Element X on phones, and Element Web and Desktop. The call goes through LiveKit, a media server on your server, and a small authorization service from Element that lets your users in.
- Legacy calls. One-to-one calls in older apps, and "Legacy Call" in Element Web, connect the two devices directly. When the devices cannot reach each other, a TURN server passes the call between them. Step 8 sets one up, with coturn. You can leave it out if everyone uses current Element apps.
It builds on the Matrix guide. LiveKit and the authorization service run under a user of their own called livekit, and coturn under one called turn.
Every step below was run on the Melonslab VC-P Alloy (2 vCPU, 8 GB) from the Matrix guide, with Debian 13:
- Two accounts made an Element Call video call in Element Web.
- They also made a legacy video call, with direct connections turned off so that it had to go through coturn.
- LiveKit's own test tool sent video to the server from outside with no packet loss, over UDP, and over TCP with UDP blocked.
- Everything came back by itself after a reboot.
LiveKit used about 70 MB of memory, the authorization service 2 MB and coturn 8 MB.
Before you start
You need:
- a Matrix server set up as in the Matrix guide;
- an A record and an AAAA record for
matrix-rtc.example.compointing at your server.
The examples use example.com, 203.0.113.10 for your server's IPv4 address and 2001:db8:1f::a for its IPv6 address. ip -brief address show eth0 shows yours. Replace them throughout.
1. Prepare the server
As root, raise the limit on how much incoming UDP Linux holds for a program. LiveKit receives calls over UDP, and warns at start when the limit is lower than this:
echo "net.core.rmem_max = 5000000" > /etc/sysctl.d/60-livekit.conf
sysctl --system
If you use ufw as in the security guide, open LiveKit's ports:
ufw allow 7881/tcp
ufw allow 7882/udp
Calls travel over UDP port 7882. TCP port 7881 is the fallback for networks that block UDP.
2. Create the user
useradd -m -s /bin/bash livekit
loginctl enable-linger livekit
machinectl shell livekit@
Everything up to step 5 runs as livekit.
3. Create the key
LiveKit and the authorization service share a key: the service signs a ticket for each person it lets into a call, and LiveKit checks it.
secret=$(openssl rand -hex 32)
printf 'matrix: %s' "$secret" | podman secret create livekit-keys -
printf %s "$secret" | podman secret create livekit-secret -
unset secret
matrix is the key's name, and the rest is the secret.
4. Describe LiveKit and the authorization service
mkdir -p ~/.config/containers/systemd
Create ~/livekit.yaml:
port: 7880
bind_addresses:
- 127.0.0.1
rtc:
tcp_port: 7881
udp_port: 7882
room:
auto_create: false
webhook:
api_key: matrix
urls:
- https://matrix-rtc.example.com/livekit/jwt/sfu_webhook
Apps talk to LiveKit on port 7880, which listens on the server's loopback address only, for Caddy. The calls themselves use ports 7881 and 7882 on the public addresses. auto_create: false means only the authorization service can open a call. The webhook tells it when someone leaves, so their place in the call is cleared.
Create ~/.config/containers/systemd/livekit.container:
[Unit]
Description=LiveKit, the media server for Matrix calls
[Container]
ContainerName=livekit
Image=docker.io/livekit/livekit-server:v1.13
Network=host
Volume=%h/livekit.yaml:/etc/livekit.yaml:ro
Secret=livekit-keys,type=env,target=LIVEKIT_KEYS
Exec=--config /etc/livekit.yaml
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
And ~/.config/containers/systemd/livekit-auth.container:
[Unit]
Description=MatrixRTC authorization service, which lets Matrix users into calls
[Container]
ContainerName=livekit-auth
Image=ghcr.io/element-hq/lk-jwt-service:latest
Network=host
Environment=LIVEKIT_JWT_BIND=127.0.0.1:8088
Environment=LIVEKIT_URL=wss://matrix-rtc.example.com/livekit/sfu
Environment=LIVEKIT_KEY=matrix
Secret=livekit-secret,type=env,target=LIVEKIT_SECRET
Environment=LIVEKIT_FULL_ACCESS_HOMESERVERS=example.com
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Both use the server's network directly, as LiveKit recommends for calls, and each listens only where its settings say. LIVEKIT_FULL_ACCESS_HOMESERVERS lets only accounts on your server start a call. People on other Matrix servers can join a call once one of your users has started it.
Start them:
systemctl --user daemon-reload
systemctl --user start livekit livekit-auth
podman logs livekit
The log ends with starting LiveKit server, with your IPv4 address as nodeIP.
5. Put Caddy in front
Go back to root with exit, and switch to machinectl shell caddy@. Add this block to the end of ~/Caddyfile:
matrix-rtc.example.com {
handle_path /livekit/jwt/* {
reverse_proxy 127.0.0.1:8088
}
handle_path /livekit/sfu/* {
reverse_proxy 127.0.0.1:7880
}
}
In the example.com block from the Matrix guide, replace the /.well-known/matrix/client line with this one, which also names the call service:
respond /.well-known/matrix/client `{"m.homeserver": {"base_url": "https://matrix.example.com"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"}]}`
Current apps ask Synapse where the call service is (step 6). Older versions look in this file instead. If your website runs somewhere else, change the file there.
Restart Caddy:
systemctl --user restart caddy
6. Tell Synapse about it
Go back to root with exit, then to machinectl shell matrix@. Add this to the end of ~/matrix/synapse.yaml:
experimental_features:
msc4143_enabled: true
msc4222_enabled: true
max_event_delay_duration: 24h
rc_message:
per_second: 0.5
burst_count: 30
rc_delayed_event_mgmt:
per_second: 1
burst_count: 20
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
matrix_rtc tells apps where to make calls. Matrix calls use a few parts of Matrix that are not final yet, and experimental_features switches them on:
- the first lets apps ask for the call service;
- the second gives apps a more accurate view of who is in a call.
max_event_delay_duration lets Synapse end your part in a call when your device disappears. The two rc_ settings raise rate limits that calls would otherwise reach. Synapse's documentation calls livekit_service_url deprecated, but Element's apps still need it.
Restart Synapse:
systemctl --user restart matrix-synapse
7. Make a call
Check the authorization service from any computer:
curl -s -o /dev/null -w '%{http_code}\n' https://matrix-rtc.example.com/livekit/jwt/healthz
It answers 200. Then open a chat in Element and start a video call. In Element Web, a chat with one other person offers Element Call and Legacy Call; choose Element Call. The other person sees a Join button. As livekit, podman logs livekit-auth shows a line with generated SFU access token for each person who joins.
8. Calls in other apps, with coturn
Skip this step if everyone uses current Element apps. It is for legacy calls, such as Legacy Call in Element Web and one-to-one calls in other Matrix apps.
As root, open coturn's ports if you use ufw, and create its user:
ufw allow 3478
ufw allow 49152:65535/udp
useradd -m -s /bin/bash turn
loginctl enable-linger turn
openssl rand -hex 32
Devices connect to port 3478, over UDP or TCP, and coturn relays each call through a port from the range. Note the secret the last command prints: coturn and Synapse both need it. Switch to machinectl shell turn@, and run mkdir -p ~/.config/containers/systemd. Create ~/turnserver.conf, with your secret and addresses:
listening-port=3478
min-port=49152
max-port=65535
realm=example.com
use-auth-secret
static-auth-secret=YOUR_SECRET
no-tcp-relay
no-multicast-peers
# Let two relayed devices reach each other through this server
allowed-peer-ip=203.0.113.10
allowed-peer-ip=2001:db8:1f::a
# Never relay into private, local or reserved networks
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.0.2.0-192.0.2.255
denied-peer-ip=192.88.99.0-192.88.99.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=198.51.100.0-198.51.100.255
denied-peer-ip=203.0.113.0-203.0.113.255
denied-peer-ip=240.0.0.0-255.255.255.255
denied-peer-ip=::1
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255
denied-peer-ip=100::-100::ffff:ffff:ffff:ffff
denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
user-quota=12
total-quota=1200
log-file=stdout
These are the settings Synapse's documentation recommends. The denied ranges stop anyone from using your TURN server to reach machines on private networks. The two allowed-peer-ip lines are your server's own addresses: when both devices in a call are relayed, the call passes from one relay to the other on this server.
Make the file readable to coturn in its container, which runs as a user of its own:
chmod 644 ~/turnserver.conf
Your home folder stays closed to other users on the server. Create ~/.config/containers/systemd/coturn.container:
[Unit]
Description=coturn, a TURN server for calls
[Container]
ContainerName=coturn
Image=docker.io/coturn/coturn:4
Network=host
Volume=%h/turnserver.conf:/etc/coturn/turnserver.conf:ro
Exec=-c /etc/coturn/turnserver.conf
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Start it, and test it: two test clients send packets to each other through the relay.
systemctl --user daemon-reload
systemctl --user start coturn
podman run --rm --network host --entrypoint turnutils_uclient docker.io/coturn/coturn:4 -y -n 20 -W YOUR_SECRET -u test 203.0.113.10
Near the end, the test reports Total lost packets 0. coturn's log warns that it has no certificate: that is expected, as this setup does not use TLS. Call media is always encrypted end to end, whichever way it travels.
Then, as matrix, add this to the end of ~/matrix/synapse.yaml, with the same secret:
turn_uris:
- turn:matrix.example.com:3478?transport=udp
- turn:matrix.example.com:3478?transport=tcp
turn_shared_secret: YOUR_SECRET
And restart Synapse with systemctl --user restart matrix-synapse. It gives each app a TURN login that expires after an hour, made with the secret, so coturn never needs a list of users.
9. Keep it up to date
As livekit, and as turn if you did step 8, switch on Podman's daily updates:
systemctl --user enable --now podman-auto-update.timer
The v1.13 tag gets every fix within LiveKit 1.13, 4 every release of coturn 4, and the authorization service follows Element's releases.
Troubleshooting
Element Web offers only Legacy Call. Synapse does not name the call service. Check the matrix_rtc block from step 6, and that Synapse was restarted.
A call starts, but nobody sees or hears anyone. The call cannot reach ports 7881 and 7882. Check the ufw rules from step 1, and podman logs livekit as livekit.
Joining a call fails with an error. As livekit, podman logs livekit-auth shows why. The service checks each person with their Matrix server, which it finds at https://example.com/.well-known/matrix/server. That file must name the port, matrix.example.com:443, as in the Matrix guide.
Legacy calls fail between different networks. Check the secret is the same in turnserver.conf and synapse.yaml, and run the test from step 8 again.