# Matrix RTC/Element Call Setup This guide assumes that you are using docker compose for deployment. LiveKit only provides Docker images. :::tip You can find help setting up MatrixRTC in our dedicated room - [#matrixrtc:continuwuity.org](https://matrix.to/#/%23matrixrtc%3Acontinuwuity.org) ::: ## Instructions ### 1. Domain LiveKit should live on its own domain or subdomain. In this guide we use `livekit.example.com` - this should be replaced with a domain you control. Make sure the DNS record for the (sub)domain you plan to use is pointed to your server. ### 2. Services Using LiveKit with Matrix requires two services - LiveKit itself, and a service (`lk-jwt-service`) that grants Matrix users permission to connect to it. You must generate a key and secret to allow the Matrix service to authenticate with LiveKit. `LK_MATRIX_KEY` should be around 20 random characters, and `LK_MATRIX_SECRET` should be around 64. Remember to replace these with the actual values! :::tip Generating the secrets LiveKit provides a utility to generate secure random keys ```bash docker run --rm livekit/livekit-server:latest generate-keys # API Key: APIUxUnMnSkuFWV # API Secret: t93ZVjPeoEdyx7Wbet3kG4L3NGZIZVEFvqe0UuiVc22A ``` ::: Create a `docker-compose.yml` file as following: ```yaml services: lk-jwt-service: image: ghcr.io/element-hq/lk-jwt-service:latest container_name: lk-jwt-service environment: - LIVEKIT_JWT_BIND=:8081 - LIVEKIT_URL=wss://livekit.example.com # your LiveKit domain - LIVEKIT_FULL_ACCESS_HOMESERVERS=example.com # your server_name # Replace these with the generated values as above - LIVEKIT_KEY=LK_MATRIX_KEY # APIUxUnMnSkuFWV - LIVEKIT_SECRET=LK_MATRIX_SECRET # t93ZVjPeoEdyx7Wbet3kG4L3NGZIZVEFvqe0UuiVc22A restart: unless-stopped ports: - "8081:8081" livekit: image: livekit/livekit-server:latest container_name: livekit command: --config /etc/livekit.yaml restart: unless-stopped volumes: - ./livekit.yaml:/etc/livekit.yaml:ro network_mode: "host" # /!\ LiveKit binds to all addresses by default. # Make sure port 7880 is blocked by your firewall to prevent access bypassing your reverse proxy # Alternatively, uncomment the lines below and comment `network_mode: "host"` above to specify port mappings. # ports: # - "127.0.0.1:7880:7880/tcp" # - "7881:7881/tcp" # - "50100-50200:50100-50200/udp" ``` Next, we need to configure LiveKit. In the same directory, create `livekit.yaml` with the following content - remembering to replace `LK_MATRIX_KEY` and `LK_MATRIX_SECRET` with the values you generated: ```yaml port: 7880 bind_addresses: - "" rtc: tcp_port: 7881 port_range_start: 50100 port_range_end: 50200 use_external_ip: true enable_loopback_candidate: false keys: LK_MATRIX_KEY: LK_MATRIX_SECRET # replace these with your key-secret pair. Example: # APIUxUnMnSkuFWV: t93ZVjPeoEdyx7Wbet3kG4L3NGZIZVEFvqe0UuiVc22A ``` Consult [`config-sample.yaml`][config-sample-yaml] for all LiveKit options. [livekit-config-sample-yaml]: https://github.com/livekit/livekit/blob/master/config-sample.yaml #### Firewall hints You will need to allow ports `7881/tcp` and `50100:50200/udp` through your firewall. If you use UFW, the commands are: `ufw allow 7881/tcp` and `ufw allow 50100:50200/udp`. ### 3. Telling clients where to find LiveKit To tell clients where to find LiveKit, you need to add the address of your `lk-jwt-service` to the `[global.matrix_rtc]` config section using the `foci` option. The variable should be a list of servers serving as MatrixRTC endpoints. Replace the URL with the address you are deploying your instance of lk-jwt-service to: ```toml [global.matrix_rtc] foci = [ { type = "livekit", livekit_service_url = "https://livekit.example.com" }, ] ``` If you configure Continuwuity via environment variables, use the following: ```bash CONTINUWUITY_MATRIX_RTC__FOCI=[{ type = "livekit", livekit_service_url = "https://livekit.example.com" }] ``` This will expose LiveKit information on the following endpoints for clients to discover: - `/_matrix/client/unstable/org.matrix.msc4143/rtc/transports` (MSC4143 unstable, behind auth) - `/.well-known/matrix/client` (fallback, not behind auth. Only enabled if `[global.well_known].client` is set) ### 4. Configure your Reverse Proxy Reverse proxies can be configured in many different ways - so we can't provide a step by step for this. All paths should be forwarded to LiveKit by default, with the exception of the following path prefixes, which should be forwarded to the JWT/Authentication service: - `/sfu/get` - `/healthz` - `/get_token`
Example caddy config ``` livekit.example.com { # for lk-jwt-service @lk-jwt-service path /sfu/get* /healthz* /get_token* route @lk-jwt-service { reverse_proxy 127.0.0.1:8081 } # for livekit reverse_proxy 127.0.0.1:7880 } ``` **Note**: if you run Caddy inside a container (e.g. by following the example [here](../deploying/docker.mdx#caddy-using-caddyfile)) instead of on the host, then: - Put these containers on the same bridge network as caddy (by defining `networks: [ caddy ]` in each of the services), and - Use appropriate container hostnames (`lk-jwt-service` and `livekit`) instead of `127.0.0.1` in the Caddyfile
Example nginx config ``` server { server_name livekit.example.com; # for lk-jwt-service location ~ ^/(sfu/get|healthz|get_token) { proxy_pass http://127.0.0.1:8081$request_uri; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host; proxy_buffering off; } # for LiveKit location / { proxy_pass http://127.0.0.1:7880$request_uri; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host; proxy_buffering off; # websocket proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } } ``` Note that for websockets to work, you need to have this somewhere outside your server block: ``` map $http_upgrade $connection_upgrade { default upgrade; '' close; } ```
Example traefik router ``` # on LiveKit itself traefik.http.routers.livekit.rule=Host(`livekit.example.com`) # on the JWT service traefik.http.routers.livekit-jwt.rule=Host(`livekit.example.com`) && (PathPrefix(`/sfu/get`) || PathPrefix(`/healthz`) || PathPrefix(`/get_token`)) ```
Example docker compose file with caddy-docker-proxy labels ```yaml # This setup assumes all containers share the same bridge network services: lk-jwt-service: image: ghcr.io/element-hq/lk-jwt-service:latest container_name: lk-jwt-service # lk-jwt-service environment config here.. labels: caddy: livekit.example.com caddy.@lk-jwt-service.path: "/sfu/get* /healthz* /get_token*" caddy.reverse_proxy: "@lk-jwt-service {{upstreams 8081}}" livekit: image: livekit/livekit-server:latest container_name: livekit command: --config /etc/livekit.yaml restart: unless-stopped labels: caddy: livekit.example.com caddy.reverse_proxy: "{{upstreams 7880}}" volumes: - ./livekit.yaml:/etc/livekit.yaml:ro ports: - "127.0.0.1:7880:7880/tcp" - "7881:7881/tcp" - "50100-50200:50100-50200/udp" caddy: image: lucaslorentz/caddy-docker-proxy:ci-alpine ports: - 80:80 - 443:443 environment: - CADDY_INGRESS_NETWORKS=caddy volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data:/data restart: unless-stopped labels: # If you already configured `[global.well_known]` with Continuwuity, # comment out the *_respond labels and add this line # caddy.reverse_proxy: /.well-known/matrix/* homeserver:8008 caddy.1_respond: /.well-known/matrix/server {"m.server":"matrix.example.com:443"} caddy.2_respond: /.well-known/matrix/client {"m.server":{"base_url":"https://matrix.example.com"},"m.homeserver":{"base_url":"https://matrix.example.com"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://livekit.example.com"}]} # If you are having problems with continuwuity serving headers uncomment # the header section below. # caddy: example.com # caddy.0_header: "*" # caddy.0_header.Access-Control-Allow-Origin: "*" # caddy.0_header.Access-Control-Allow-Methods: "GET, POST, OPTIONS" # caddy.0_header.Access-Control-Allow-Headers: "Authorization" # caddy.0_header.Content-Type: "application/json" homeserver: image: forgejo.ellis.link/continuwuation/continuwuity:latest restart: unless-stopped # add additional environment, volume, and network config here... labels: caddy: matrix.example.com caddy.reverse_proxy: "{{upstreams 8008}}" ```
### 6. Start Everything Start up the services using your usual method - for example `docker compose up -d`. ## Testing To test that LiveKit is successfully integrated with Continuwuity, you will need to replicate its [Token Exchange Flow](https://github.com/element-hq/lk-jwt-service#%EF%B8%8F-how-it-works--token-exchange-flow). Follow the steps below while checking Docker logs (`docker-compose logs --follow`), in order to help [troubleshooting](#troubleshooting) any issues. First, you will need an access token for your current login session. These can be found in your client's settings or obtained via [this website](https://timedout.uk/mxtoken.html). Then, using that token, fetch the discovery endpoints for MatrixRTC services: ```bash curl -H "Authorization: Bearer " \ https://matrix.example.com/_matrix/client/unstable/org.matrix.msc4143/rtc/transports ``` In the output, you should see the LiveKit URL matching the one [configured above](#3-telling-clients-where-to-find-livekit). With the same token, request another OpenID token for use with the lk-jwt-service: ```bash curl -X POST -H "Authorization: Bearer " \ https://matrix.example.com/_matrix/client/v3/user/@user:example.com/openid/request_token ``` You will see a response as below: ```json {"access_token":"","token_type":"Bearer","matrix_server_name":"example.com","expires_in":3600} ``` Next, create a `payload.json` file with the following content:
`payload.json` ```json { "room_id": "abc", "slot_id": "xyz", "openid_token": { "matrix_server_name": "example.com", "access_token": "", "token_type": "Bearer" }, "member": { "id": "xyz", "claimed_device_id": "DEVICEID", "claimed_user_id": "@user:example.com" } } ``` Replace `matrix_server_name` and `claimed_user_id` with your information, and `` with the one you got from the previous step. Other values can be left as-is.
You can then send this payload to the lk-jwt-service: ```bash curl -X POST -d @payload.json https://livekit.example.com/get_token ``` The lk-jwt-service will, after checking against Continuwuity, answer with a `jwt` token to create a LiveKit media room: ```json {"url":"wss://livekit.example.com","jwt":"a_really_really_long_string"} ``` Use this token to test at the [LiveKit Connection Tester](https://livekit.io/connection-test). If everything works there, then you have set up LiveKit successfully! ## Troubleshooting To debug any issues, you can place a call or redo the Testing instructions, and check the container logs for any specific errors. Use `docker-compose logs --follow` to follow these logs in real-time. ### Common errors in Element Call UI - `MISSING_MATRIX_RTC_FOCUS`: LiveKit is missing from Continuwuity's config file - "Waiting for media" popup always showing: a LiveKit URL has been configured in Continuwuity, but your client cannot connect to it for some reason For browser-based clients, you can also inspect connections using DevTools' Networking tab, to see which requests are erroring out. ### Docker loopback networking issues Some distros do not allow Docker containers to connect to its host's public IP by default. This would cause `lk-jwt-service` to fail connecting to `livekit` or `continuwuity` on the same host. As a result, you would see connection refused/connection timeouts log entries in the JWT service, even when `LIVEKIT_URL` has been configured correctly. You can also test that this is the case by cURLing from a sidecar container: ```bash docker run --rm --net container:lk-jwt-service docker.io/curlimages/curl https://livekit.example.com # --- some errors --- ``` To alleviate this, you can try one of the following workarounds: - Use `network_mode: host` for the `lk-jwt-service` container (instead of the default bridge networking). - Add an `extra_hosts` file mapping livekit's (and continuwuity's) domain name to a locally-reachable address: ```diff # in docker-compose.yaml services: lk-jwt-service: ... + extra_hosts: + - "livekit.example.com:127.0.0.1" + - "matrix.example.com:127.0.0.1" ``` - (**untested, use at your own risk**) Implement an iptables workaround as shown [here](https://forums.docker.com/t/unable-to-connect-to-host-service-from-inside-docker-container/145749/6). After implementing the changes and restarting your compose, `lk-jwt-service` should now connect to your other services. The sidecar container test above should now return an `OK` from LiveKit. ### Workaround for non-federating servers When deploying on servers with federation disabled (`allow_federation = false`), LiveKit will fail as it can't fetch the required [OpenID endpoint](https://spec.matrix.org/v1.17/server-server-api/#get_matrixfederationv1openiduserinfo) via federation paths. As a workaround, you can enable federation, but forbid all remote servers via the following config parameters: ```toml ### in your continuwuity.toml file ### allow_federation = true forbidden_remote_server_names = [".*"] ``` Subscribe to issue [!1440](https://forgejo.ellis.link/continuwuation/continuwuity/issues/1440) for future updates on this matter. ## Related Documentation Guides: - [Element Call self-hosting documentation from element-hq][element-call-selfhosting] - [Community guide with overview of LiveKit's mechanisms][tom-livekit-guide] - [Community guide using systemd][kimiblock-livekit-guide] [element-call-selfhosting]: https://github.com/element-hq/element-call/blob/livekit/docs/self-hosting.md [tom-livekit-guide]: https://tomfos.tr/matrix/livekit/ [kimiblock-livekit-guide]: https://blog.kimiblock.top/2024/12/24/hosting-element-call/ Configurations: - [Livekit's `config-sample.yaml`][livekit-config-sample-yaml] - LiveKit configuration file with full options Specifications: - [MSC4143 - MatrixRTC proposal][MSC4143] - [MSC4195 - LiveKit proposal][MSC4195] [MSC4143]: https://github.com/matrix-org/matrix-spec-proposals/pull/4143 [MSC4195]: https://github.com/matrix-org/matrix-spec-proposals/pull/4195 Source code: - [Element Call][element-call-github] - [lk-jwt-service][lk-jwt-service-github] - [LiveKit server][livekit-server-github] [element-call-github]: https://github.com/element-hq/element-call [lk-jwt-service-github]: https://github.com/element-hq/lk-jwt-service [livekit-server-github]: https://github.com/livekit/livekit Other: - [Matrix VOIP and LiveKit][sspaeth-matrix-voip] - Community member deep dive on current VOIP solutions on Matrix [sspaeth-matrix-voip]: https://sspaeth.de/2026/04/matrix-voip-and-livekit/ ## Appendix ### Additional TURNS-over-443 configuration Most of the time, LiveKit [**does not need TURN**][sspaeth-matrix-voip-turn] to function. However, there are situations where clients are in very restrictive networks that disallows non-standard ports and UDP. In these cases, a TURN-over-TLS server on port :443 could be employed to relay traffic for them. [sspaeth-matrix-voip-turn]: https://sspaeth.de/2026/04/matrix-voip-and-livekit/#turn You can either use LiveKit's built-in TURN server, or integrate LiveKit with [coturn](./turn).
Using LiveKit's built-in TURN server First, set up LiveKit's built-in TURN server with its own domain - we'll use `livekit-turn.example.com` in our example. ```yaml ## add this to `livekit.yaml` ## turn: enabled: true # note: the TLS port will always be advertised as :443 tls_port: 5349 # optional: configure an extra UDP port on :3478 # udp_port: 3478 relay_range_start: 50300 relay_range_end: 50400 domain: livekit-turn.example.com # replace these with your actual cert/key files cert_file: /path/to/livekit-turn.example.com.crt key_file: /path/to/livekit-turn.example.com.key ``` ```yaml ### add these ports to livekit's docker-compose ### ### if you're using `network_mode: host`, you can skip this part ports: - "127.0.0.1:5349:5349/tcp" - "50300-50400:50300-50400/udp" # "3478:3478/udp" # (optional UDP port) ``` Recreate the LiveKit container (with `docker-compose up -d livekit`) to apply these changes. Remember to allow the new `50300:50400/udp` ports through your firewall. Then, we will configure a route from port 443 of the host back to our `livekit-turn.example.com` service on port 5349. To both **multiplex** this and LiveKit's websocket on the same port, we will use a layer-4 reverse proxy with **SNI routing** capabilities, such as [caddy-l4][caddy-l4] on the host system. ``` ## in your Caddyfile ## { servers { listener_wrappers { # intercept packets meant for the TURN domain first # before forwarding other packets to "normal" HTTP listeners layer4 { @turn tls sni livekit-turn.example.com route @turn { proxy 127.0.0.1:5349 # forward to normal TURNS port } } } tls } } } # livekit stuff https://livekit.example.com { @lk-jwt-service path /healthz /get_token /sfu/get route @lk-jwt-service { reverse_proxy 127.0.0.1:8081 } reverse_proxy http://127.0.0.1:7880 } ``` [caddy-l4]: https://github.com/mholt/caddy-l4
Using an external TURN server (coturn) Before continuing, make sure coturn's `min-port` and `max-port` do not overlap with LiveKit's port range: ```ini # in your coturn.conf min-port=50201 max-port=65535 ``` Then, generate a long random secret for LiveKit, and add it to your coturn config under the `static-auth-secret` option. You can add as many secrets as you want, so set a different one for LiveKit to use. After that, refer to the following [**TURN instructions**](./turn#turns-over-443) to set up coturn with TLS, as well as multiplexing with LiveKit's websocket on port 443. Then configure LiveKit, making sure to replace `COTURN_SECRET` with the one you generated: ```yaml # livekit.yaml rtc: turn_servers: - host: coturn.example.com port: 443 protocol: tls secret: "COTURN_SECRET" ``` Restart LiveKit, coturn, and Caddy-l4 to apply these changes.
After finishing configuration, you can run the Testing steps again to check that TURN-over-TLS is working. In the LiveKit connection test page, there should be a green tick saying "Can connect to TURN".