This is an old revision of the document!
# Element Call (LiveKit) — Rootless Podman Quadlet
Rootless Podman as user `worker` on host `support` (`8.28.86.82`, `2604:fa40:0:10::9`). Apache terminates TLS for the two HTTP names. The containers speak plain HTTP on localhost. TURN is UDP on 3479. Talk keeps 3478. Apache keeps TCP 443. Synapse stays on the Matrix VM.
| 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 |
Do not use 8080, 8082, 8088, 3478, or 443.
Images:
- `ghcr.io/element-hq/element-call:v0.26.1` - `ghcr.io/element-hq/lk-jwt-service:latest` - `docker.io/livekit/livekit-server:latest`
Do not use `podman generate systemd`. Units come from Quadlet files in `~/.config/containers/systemd/`.
—
## 1. DNS
`call.gnulinux.club` and `webrtc.gnulinux.club` → `8.28.86.82` and `2604:fa40:0:10::9`.
—
## 2. Firewall (root)
```bash ufw allow 3479/udp comment 'Element Call TURN UDP' ufw allow 35000:35100/udp comment 'Element Call TURN relay' ufw allow 7881/tcp comment 'Element Call ICE TCP' ufw allow 50100:50200/udp comment 'Element Call ICE UDP' ```
—
## 3. Secret (worker)
```bash mkdir -p ~/element-call umask 077 openssl rand -hex 32 | tee ~/element-call/livekit.secret chmod 600 ~/element-call/livekit.secret ```
Key name: `matrixrtc`. Placeholder used below:
``` LIVEKIT_SECRET=replace-with-your-livekit-secret ```
—
## 4. Certificates (root)
```bash certbot certonly –apache -d call.gnulinux.club -d webrtc.gnulinux.club a2enmod proxy proxy_http proxy_wstunnel headers rewrite ssl ```
Apache is the only process that reads these.
—
## 5. LiveKit config (worker)
```bash cat > ~/element-call/livekit.yaml « 'EOF' port: 7880 bind_addresses:
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`.
```bash 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`.
```bash 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
```bash 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.
```bash #!/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`:
```bash 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`:
```apache <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`:
```apache <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> ```
```bash 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`.
```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"
```
```bash 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:
```bash 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`.
```json {
"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:
```nginx 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;
}
} ```
```bash 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:
```json “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
-