User Tools

Site Tools


computing:element-call

This is an old revision of the document!



  • 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)

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.

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.

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

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.

8. Verify

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.

9. Upgrade script

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

10. Apache (root)

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

11. Synapse (Matrix VM)

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.

12. Well-known (Matrix VM)

/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

13. Element Web

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.

Facts

  • UD

— oemb1905 2026/10/10 01:06

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