Element Call
This tutorial is for Debian users who already self-host Matrix-Synapse and need to build an Element Call instance to support it with. Now, in my case, I manually self-host Matrix-Synapse. But/and, when I built this instance, Element Call did not exist - Jitsi was not only an add-on, but the standard. Over time, that changed, but/and I had never set it up. I eventually got to it about a year ago and then again about 8 months ago, but failed both times. Manual installation has proven difficult. Given I had recently setup my new virtual docker/OCI host, I thought this might be a perfect use-case for it. Here's the overall layout of what I came up with:
| Name | Container | Bind | Public |
|---|---|---|---|
| Element Call UI | element-call | 127.0.0.1:8083 → 8080 | https://call.gnulinux.club |
| JWT | lk-jwt | 127.0.0.1:8085 → 8080 | https://webrtc.gnulinux.club/livekit/jwt/ |
| LiveKit signalling | livekit | 127.0.0.1:7880 → 7880 | https://webrtc.gnulinux.club/livekit/sfu/ |
| ICE TCP | same | 7881/tcp | 8.28.86.82:7881 |
| ICE UDP | same | 50100-50200/udp | that range |
| TURN listen | same | 3479/udp | webrtc.gnulinux.club:3479 |
| TURN relay | same | 35000-35100/udp | that range |
Since this was the 4th container on the same instance, I need to avoid using ports 8080, 8082, 8088, 3478, and 443. I just used 3479 instead, since HPB was already using 3478. The main proxy port was also easy to adjust.
Images:
ghcr.io/element-hq/element-call:v0.26.1ghcr.io/element-hq/lk-jwt-service:latestdocker.io/livekit/livekit-server:latestSet your A/AAAA records to point at the virtual machine / OCI container host. The reverse proxy we set up later will handle the rest.
Here's the ufw rules I came up with:
ufw allow 3479/udp ufw allow 35000:35100/udp ufw allow 7881/tcp ufw allow 50100:50200/udp
Let's get the secrets cut:
mkdir -p ~/element-call umask 077 openssl rand -hex 32 | tee ~/element-call/livekit.secret chmod 600 ~/element-call/livekit.secret
After the key and project directory are ready, we can cut the cert:
certbot certonly --apache -d call.gnulinux.club -d webrtc.gnulinux.club a2enmod proxy proxy_http proxy_wstunnel headers rewrite ssl
Let's pull the images and create our configuration files:
podman pull ghcr.io/element-hq/element-call:v0.26.1
podman pull ghcr.io/element-hq/lk-jwt-service:latest
podman pull docker.io/livekit/livekit-server:latest
cat > ~/element-call/livekit.yaml << 'EOF'
port: 7880
bind_addresses:
- "0.0.0.0"
rtc:
tcp_port: 7881
port_range_start: 50100
port_range_end: 50200
node_ip: 8.28.86.82
use_external_ip: true
room:
auto_create: false
logging:
level: info
keys:
matrixrtc: "replace-with-your-livekit-secret"
webhook:
api_key: matrixrtc
urls:
- https://webrtc.gnulinux.club/livekit/jwt/sfu_webhook
turn:
enabled: true
domain: webrtc.gnulinux.club
udp_port: 3479
relay_range_start: 35000
relay_range_end: 35100
EOF
chmod 600 ~/element-call/livekit.yaml
Make sure auto_create: false is declared - The JWT service creates rooms.
The image serves files from /app. A mount on /usr/share/nginx/html is ignored. The file must exist before the container starts, or Podman creates a directory and nginx falls through to index.html. On the VM, establish the endpoint:
cat > ~/element-call/config.json << 'EOF'
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.gnulinux.club",
"server_name": "gnulinux.club"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://webrtc.gnulinux.club/livekit/jwt"
}
]
}
}
EOF
Also on the VM, make sure the quadlet is setup. You should note that lk-jwt must resolve webrtc.gnulinux.club which is the host. Without AddHost, room creation hairpins to the public IP and gets connection refused so we define the hostname inside the container with an extra line to address this failure. The other thing is LIVEKIT_FULL_ACCESS_HOMESERVERS is gnulinux.club, not matrix.gnulinux.club.
systemctl --user disable --now container-element-call.service container-lk-jwt.service container-livekit.service 2>/dev/null || true
rm -f ~/.config/systemd/user/container-element-call.service \
~/.config/systemd/user/container-lk-jwt.service \
~/.config/systemd/user/container-livekit.service
podman rm -f element-call lk-jwt livekit
mkdir -p ~/.config/containers/systemd
cat > ~/.config/containers/systemd/container-element-call.container << 'EOF'
[Container]
ContainerName=element-call
Image=ghcr.io/element-hq/element-call:v0.26.1
PublishPort=127.0.0.1:8083:8080
Volume=/home/worker/element-call/config.json:/app/config.json:ro,Z
PodmanArgs=--cpus=1 --memory=512m
[Service]
Restart=always
TimeoutStopSec=120
[Install]
WantedBy=default.target
EOF
cat > ~/.config/containers/systemd/container-lk-jwt.container << 'EOF'
[Container]
ContainerName=lk-jwt
Image=ghcr.io/element-hq/lk-jwt-service:latest
PublishPort=127.0.0.1:8085:8080
AddHost=webrtc.gnulinux.club:host-gateway
Environment=LIVEKIT_JWT_BIND=:8080
Environment=LIVEKIT_URL=wss://webrtc.gnulinux.club/livekit/sfu
Environment=LIVEKIT_KEY=matrixrtc
Environment=LIVEKIT_SECRET=replace-with-your-livekit-secret
Environment=LIVEKIT_FULL_ACCESS_HOMESERVERS=gnulinux.club
PodmanArgs=--cpus=1 --memory=512m
[Service]
Restart=always
TimeoutStopSec=120
[Install]
WantedBy=default.target
EOF
cat > ~/.config/containers/systemd/container-livekit.container << 'EOF'
[Container]
ContainerName=livekit
Image=docker.io/livekit/livekit-server:latest
PublishPort=127.0.0.1:7880:7880/tcp
PublishPort=7881:7881/tcp
PublishPort=3479:3479/udp
PublishPort=35000-35100:35000-35100/udp
PublishPort=50100-50200:50100-50200/udp
Volume=/home/worker/element-call/livekit.yaml:/etc/livekit.yaml:Z
Exec=--config /etc/livekit.yaml
PodmanArgs=--cpus=2 --memory=4g
[Service]
Restart=always
TimeoutStopSec=120
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user reset-failed container-element-call.service container-lk-jwt.service container-livekit.service
systemctl --user start container-livekit.service
systemctl --user start container-lk-jwt.service
systemctl --user start container-element-call.service
Let's make sure those quadlets all function:
systemctl --user is-active container-livekit.service container-lk-jwt.service container-element-call.service
podman inspect element-call lk-jwt livekit --format '{{.Name}} cpus={{.HostConfig.NanoCpus}} memory={{.HostConfig.Memory}}'
curl -fsS http://127.0.0.1:8085/healthz; echo
curl -sS http://127.0.0.1:8083/config.json; echo
podman logs --tail 15 livekit
Here, we are looking for active on all three and config.json must be JSON, not the HTML page. LiveKit should log turn.portUDP 3479, relay_range_start 35000, and nodeIP 8.28.86.82 and/or the instance's IP.
Here's a simple upgrade script for the OCI container. Create nano /usr/local/bin/upgrade-element-call.sh. In that file, place:
#!/bin/bash
set -euo pipefail
export XDG_RUNTIME_DIR=/run/user/$(id -u)
export DBUS_SESSION_BUS_ADDRESS=unix:path=${XDG_RUNTIME_DIR}/bus
podman pull docker.io/livekit/livekit-server:latest
podman pull ghcr.io/element-hq/lk-jwt-service:latest
podman pull ghcr.io/element-hq/element-call:v0.26.1
systemctl --user stop container-livekit.service
systemctl --user stop container-lk-jwt.service
systemctl --user stop container-element-call.service
systemctl --user daemon-reload
systemctl --user reset-failed container-livekit.service container-lk-jwt.service container-element-call.service
systemctl --user start container-livekit.service
systemctl --user start container-lk-jwt.service
systemctl --user start container-element-call.service
curl -fsS http://127.0.0.1:8085/healthz
echo
curl -sS http://127.0.0.1:8083/config.json
echo
Now that the service is created and running, make sure dns for a/aaaa is ready and then let's cut the cert.
sudo certbot certonly --apache -d call.gnulinux.club sudo certbot certonly --apache -d webrtc.gnulinux.club
We also need to create the reverse proxy so we can forward external requests upstream to the local listening services we just created. For the cert(s) nano /etc/apache2/sites-available/call.gnulinux.club.conf and drop in:
<VirtualHost *:80>
ServerName call.gnulinux.club
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
Inside nano /etc/apache2/sites-available/call.gnulinux.club-ssl.conf something like:
<VirtualHost *:443>
ServerName call.gnulinux.club
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/call.gnulinux.club/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/call.gnulinux.club/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyPass / http://127.0.0.1:8083/
ProxyPassReverse / http://127.0.0.1:8083/
</VirtualHost>
Inside nano /etc/apache2/sites-available/webrtc.gnulinux.club.conf:
<VirtualHost *:80>
ServerName webrtc.gnulinux.club
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
Inside nano /etc/apache2/sites-available/webrtc.gnulinux.club-ssl.conf:
<VirtualHost *:443>
ServerName webrtc.gnulinux.club
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/webrtc.gnulinux.club/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/webrtc.gnulinux.club/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
ProxyPreserveHost On
RequestHeader set X-Forwarded-Proto "https"
ProxyTimeout 3600
ProxyPass /livekit/jwt/ http://127.0.0.1:8085/
ProxyPassReverse /livekit/jwt/ http://127.0.0.1:8085/
RewriteEngine On
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/livekit/sfu/(.*) ws://127.0.0.1:7880/$1 [P,L]
ProxyPass /livekit/sfu/ http://127.0.0.1:7880/
ProxyPassReverse /livekit/sfu/ http://127.0.0.1:7880/
</VirtualHost>
Once that's done, let's enable them and check the endpoints:
a2ensite call.gnulinux.club.conf webrtc.gnulinux.club.conf
apache2ctl configtest && systemctl reload apache2
curl -fsS -o /dev/null -w 'jwt %{http_code}\n' https://webrtc.gnulinux.club/livekit/jwt/healthz
curl -fsS -o /dev/null -w 'call %{http_code}\n' https://call.gnulinux.club/
curl -s https://call.gnulinux.club/config.json
Both status lines must be 200. The config curl must be JSON.
On the Matrix VM, we need to edit nano /etc/matrix-synapse/homeserver.yaml:
experimental_features:
msc3266_enabled: true
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://webrtc.gnulinux.club/livekit/jwt"
Then, restart the service:
sudo systemctl restart matrix-synapse
You can test with:
curl -s -H "Authorization: Bearer TOKEN" \ https://matrix.gnulinux.club/_matrix/client/unstable/org.matrix.msc4143/rtc/transports
The body of the output should contain https://webrtc.gnulinux.club/livekit/jwt.
In an initial build, I left old comments/notes inside nano /var/www/gnulinux.club/.well-known/matrix/client commented out at the end, but the API parser can't handle comments. Everything - literally - must be valid JSON. The comments made Element fail and show MISSING_MATRIX_RTC_TRANSPORT. The new nano /var/www/gnulinux.club/.well-known/matrix/client should look like:
{
"m.homeserver": {
"base_url": "https://matrix.gnulinux.club"
},
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://webrtc.gnulinux.club/livekit/jwt"
}
]
}
The nginx vhost for gnulinux.club must allow the Element Call origin to read it:
location /.well-known/matrix/client {
default_type application/json;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization" always;
if ($request_method = OPTIONS) {
return 204;
}
}
Reload service and check API endpoint:
nginx -t && systemctl reload nginx curl -s https://gnulinux.club/.well-known/matrix/client
In element.gnulinux.club config.json, replace the hosted call URL block with the following:
"element_call": {
"url": "https://call.gnulinux.club",
"use_exclusively": true,
"participant_limit": 8,
"brand": "Element Call"
}
That should be it. Debug and review line by line if stuff is failing. Reach out on Matrix if needed.
— oemb1905 2026/10/10 05:54