------------------------------------------- * **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//