# 🧩 Matrix Stack > Self-hosted Matrix homeserver with native voice & video calling, TURN relay, and LiveKit WebRTC integration. Built on **[Tuwunel](https://github.com/jevolk/tuwunel)** — a fast, modern Matrix homeserver — with full [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143) support for in-room calls via LiveKit. --- ## 📦 Services | Container | Image | Role | |---|---|---| | `tuwunel` | `jevolk/tuwunel` | Matrix homeserver | | `livekit` | `livekit/livekit-server` | WebRTC SFU — voice & video | | `lk-jwt-service` | `ghcr.io/element-hq/lk-jwt-service` | Issues LiveKit tokens to Matrix clients | | `coturn` | `coturn/coturn` | TURN/STUN relay for NAT traversal | --- ## ⚙️ Configuration ### 🔑 Environment variables Copy `.env.example` to `.env` and fill in all values before starting the stack. ```bash cp .env.example .env ``` | Variable | Description | |---|---| | `DOMAIN_NAME` | Your base domain — e.g. `example.com` | | `EXTERNAL_IP` | Your server's public IPv4 address | | `STATIC_AUTH_SECRET` | Shared TURN secret between coturn and tuwunel | | `API_KEY` | LiveKit API key | | `API_SECRET` | LiveKit API secret | | `NETWORK_NAME` | External Docker network shared across stacks — default `backbone` | | `GIT_SHA` | Image tag to deploy — use `latest` for development | **Generating secrets:** | Variable | Command | |---|---| | `STATIC_AUTH_SECRET` | `openssl rand -hex 32` | | `API_SECRET` | `openssl rand -hex 32` | | `API_KEY` | `openssl rand -hex 16` | | `EXTERNAL_IP` | `curl -4 ifconfig.me` | > ⚠️ `.env` is gitignored and must **never** be committed. --- ### 🔁 Secret rotation `scripts/rotate-secrets.sh` regenerates secrets in `.env` and restarts only the affected containers. ```bash bash scripts/rotate-secrets.sh # rotate everything bash scripts/rotate-secrets.sh --turn # TURN secret only (restarts coturn + tuwunel) bash scripts/rotate-secrets.sh --livekit # LiveKit keys only (restarts livekit + lk-jwt-service) ``` > ⚠️ Active calls and sessions will be dropped on rotation. Run during a maintenance window. Rotation events are logged to `scripts/rotation.log` (gitignored). **To run on a schedule** — add a cron entry on the host: ```bash crontab -e ``` ```cron # Rotate all Matrix secrets every 90 days at 3am 0 3 1 */3 * cd /opt/stacks/matrix && bash scripts/rotate-secrets.sh --all >> /var/log/matrix-rotation.log 2>&1 ``` --- ### 📝 Variable substitution — what works where Docker Compose substitutes `${VAR}` **only inside `docker-compose.yml`**. Config files mounted as volumes are read as plain text — variables are not expanded inside them. | File | Substitution | How secrets get in | |---|---|---| | `docker-compose.yml` | ✅ Yes | Directly from `.env` | | `tuwunel/tuwunel.toml` | ❌ No | `server_name` is a required field — update it to your domain. `TUWUNEL_SERVER_NAME` in compose overrides it at runtime but the field must be present | | `coturn/turnserver.conf` | ❌ No | Secret and realm passed as CLI args in compose | | `livekit/livekit.yaml` | ❌ No | API keys passed via `LIVEKIT_KEYS` env var in compose | --- ## 🚀 Deployment ### Prerequisites Both of these must exist before the stack will start — Docker will refuse to create containers otherwise. **1. Create your Docker network** (set `NETWORK_NAME` in `.env` to match): ```bash docker network create backbone ``` **2. Create the tuwunel data volume:** ```bash docker volume create matrix_tuwunel-data ``` ### Start the stack ```bash docker compose up -d ``` --- ## 🔀 Reverse Proxy This stack does not run its own Caddy instance. Routing is handled by a shared Caddy container on the `backbone` Docker network. Add the following blocks to your Caddyfile. > 💡 **Always use `handle` blocks.** Do not mix bare `respond` directives with a `handle {}` catch-all in the same site block — Caddy's `handle {}` will intercept all requests before a bare `respond` can fire. --- ### 🌐 Base domain — client & server discovery Serves the `.well-known/matrix` endpoints that tell clients where the homeserver and LiveKit service are. Required for login and calling to work. ```caddy your.domain { handle /.well-known/matrix/server { header Content-Type application/json header Access-Control-Allow-Origin * respond `{"m.server":"matrix.your.domain:443"}` } handle /.well-known/matrix/client { header Content-Type application/json header Access-Control-Allow-Origin * respond `{ "m.homeserver": { "base_url": "https://matrix.your.domain" }, "org.matrix.msc4143.rtc_foci": [ { "type": "livekit", "livekit_service_url": "https://livekit.your.domain" } ] }` } handle { respond 404 } } ``` --- ### 🏠 Matrix homeserver Routes Matrix API traffic to tuwunel. WebSocket headers are required for client sync connections. ```caddy matrix.your.domain { handle /_version { respond `{"sha":"{env.GIT_SHA}"}` } handle /_matrix/* { reverse_proxy tuwunel:6167 { header_up Upgrade {http.request.header.Upgrade} header_up Connection {http.request.header.Connection} } } handle /_synapse/* { reverse_proxy tuwunel:6167 { header_up Upgrade {http.request.header.Upgrade} header_up Connection {http.request.header.Connection} } } handle { respond 404 } } ``` --- ### 🔗 Federation — port 8448 Handles server-to-server federation traffic. Requires port `8448` exposed on your Caddy container. ```caddy matrix.your.domain:8448 { reverse_proxy tuwunel:6167 } ``` ```yaml # caddy docker-compose.yml ports: - "8448:8448" ``` --- ### 📹 LiveKit Routes JWT token requests to `lk-jwt-service` and all WebRTC traffic to the LiveKit server. LiveKit runs with `network_mode: host` and is **not** on the Docker network — it is reached via `host.docker.internal`. This requires the following in your Caddy compose: ```yaml extra_hosts: - "host.docker.internal:host-gateway" ``` ```caddy livekit.your.domain { handle /sfu/* { reverse_proxy lk-jwt-service:8080 } handle /livekit/jwt* { reverse_proxy lk-jwt-service:8080 } handle { reverse_proxy host.docker.internal:7880 { header_up Upgrade {http.request.header.Upgrade} header_up Connection {http.request.header.Connection} } } } ``` > ⚠️ WebSocket headers on the LiveKit catch-all are **required**. Omitting them causes `MISSING_MATRIX_RTC_TRANSPORT` errors on clients. --- ## 📋 Network topology ![NetworkDiagram](NetworkDiagram.svg) ``` Backbone network (Docker) | Ingress | Routes to | |---|---| | `Caddy` (HTTPS) | `tuwunel:6167` | | | `lk-jwt-service` | | | `host.docker.internal:7880` (livekit) | Host network (UDP) | Service | Ports | |---|---| | livekit | 7880, 7881, 50000–60000 | | coturn | 3478, 5349, 49152–65535 | ``` --- ## 📁 Structure ``` . ├── tuwunel/ │ └── tuwunel.toml # Homeserver config — hardcode server_name here ├── livekit/ │ └── livekit.yaml # LiveKit ports and RTC config ├── coturn/ │ ├── turnserver.conf # TURN config — no secrets, those go in compose │ └── certs/ # Drop fullchain.pem + privkey.pem here to enable TURNS (gitignored) ├── docker-compose.yml ├── .env # Secrets — gitignored, never commit └── .env.example # Template — safe to commit ``` --- ## 🔒 Security notes - `coturn` blocks RFC1918 ranges in `turnserver.conf` to prevent TURN relay abuse - Matrix registration is disabled by default in `tuwunel/tuwunel.toml` --- ## 🔐 Enabling TURNS (TURN over TLS) TURNS encrypts relay traffic on port `5349`. The stack is pre-wired for it — `coturn/certs/` is already mounted into the container and `TUWUNEL_TURN_URIS` already advertises the `turns:` URI to clients. It just needs certs and the config uncommented. **To enable:** 1. Drop your certificates into `coturn/certs/`: ``` coturn/certs/fullchain.pem coturn/certs/privkey.pem ``` Certs must be valid for `turn.DOMAIN_NAME`. If you have a wildcard cert (`*.yourdomain.com`) it will work as-is. 2. Uncomment the TLS lines in `coturn/turnserver.conf`: ``` tls-listening-port=5349 cert=/etc/coturn/certs/fullchain.pem pkey=/etc/coturn/certs/privkey.pem ``` 3. Restart coturn: ```bash docker compose up -d coturn ``` > `coturn/certs/` is gitignored — your private key will never be committed.