User Tools

Site Tools


computing:element-call

  • element-call
  • Jonathan Haack
  • Haack's Networking
  • webmaster@haacksnetworking.org

Element Call


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 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:

<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.

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.

— oemb1905 2026/10/10 05:54

computing/element-call.txt · Last modified: by oemb1905