This is an old revision of the document!
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
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
node_ip is the public IPv4 clients use for media. It does not take Apache's TCP 443. auto_create: false is required. 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.
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
lk-jwt must resolve webrtc.gnulinux.club to the host. Without AddHost, room creation hairpins to the public IP and gets connection refused. 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
WantedBy=default.target starts them. Do not systemctl enable a Quadlet unit. Do not podman generate systemd.
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
Expect active on all three. 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.
/usr/local/bin/upgrade-element-call.sh. A tag change is an edit to Image= in the matching .container file before daemon-reload. The script does not recreate the units.
#!/bin/bash set -euo pipefail 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
Run it as worker:
su - worker -c '/bin/bash /usr/local/bin/upgrade-element-call.sh'
Do not sudo -u worker. That drops the session bus.
/etc/apache2/sites-available/call.gnulinux.club.conf:
<VirtualHost *:80>
ServerName call.gnulinux.club
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
</VirtualHost>
<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>
/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>
<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>
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.
No new ports. Existing HTTPS is enough. Append this at the bottom of /etc/matrix-synapse/homeserver.yaml, same indent as pid_file. Do not nest it under email or database.
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"
sudo systemctl restart matrix-synapse
Wait until it is active. A curl during those few seconds returns nginx 502. The LiveKit secret is not added here. Synapse only advertises the JWT URL. lk-jwt checks the OpenID token.
This Synapse serves the unstable route only. /_matrix/client/v1/rtc/transports returns M_UNRECOGNIZED. Element Call uses the unstable route. That is expected.
With a real Element access token:
curl -s -H "Authorization: Bearer TOKEN" \ https://matrix.gnulinux.club/_matrix/client/unstable/org.matrix.msc4143/rtc/transports
The body must contain https://webrtc.gnulinux.club/livekit/jwt.
/var/www/gnulinux.club/.well-known/matrix/client must be valid JSON. No # comments. A comment makes Element fail parse and show MISSING_MATRIX_RTC_TRANSPORT.
{
"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;
}
}
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:
"element_call": {
"url": "https://call.gnulinux.club",
"use_exclusively": true,
"participant_limit": 8,
"brand": "Element Call"
}
https://call.element.io shows MISSING_MATRIX_RTC_TRANSPORT even when Synapse is correct. Hard-refresh after the change.
— oemb1905 2026/10/10 01:06