-------------------------------------------
* **element-call**
* **Jonathan Haack**
* **Haack's Networking**
* **webmaster@haacksnetworking.org**
-------------------------------------------
//Element Call//
-------------------------------------------
~~NOTOC~~
==== Introduction ====
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 [[https://wiki.haacksnetworking.org/doku.php?id=computing:synapse|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.1''
* ''ghcr.io/element-hq/lk-jwt-service:latest''
* ''docker.io/livekit/livekit-server:latest''
==== 1. DNS ====
Set your A/AAAA records to point at the virtual machine / OCI container host. The reverse proxy we set up later will handle the rest.
==== 2. Firewall (root) ====
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
==== 3. Secret (worker) ====
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
==== 4. Certificates (root) ====
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
==== 5. LiveKit config (worker) ====
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.
==== 6. Element Call config (worker) ====
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
==== 7. Quadlets ====
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
==== 8. Verify ====
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.
==== 9. Upgrade script ====
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
==== 10. Apache (root) ====
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:
ServerName call.gnulinux.club
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
Inside ''nano /etc/apache2/sites-available/call.gnulinux.club-ssl.conf'' something like:
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/
Inside ''nano /etc/apache2/sites-available/webrtc.gnulinux.club.conf'':
ServerName webrtc.gnulinux.club
RewriteEngine On
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
Inside ''nano /etc/apache2/sites-available/webrtc.gnulinux.club-ssl.conf'':
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/
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.
==== 11. Synapse (Matrix VM) ====
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''.
==== 12. Well-known (Matrix VM) ====
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
==== 13. Element Web ====
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.
--- //[[alerts@haacksnetworking.org|oemb1905]] 2026/10/10 05:54//