🧩 Matrix Stack
Self-hosted Matrix homeserver with native voice & video calling, TURN relay, and LiveKit WebRTC integration.
Built on Tuwunel — a fast, modern Matrix homeserver — with full MSC4143 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.
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 |
⚠️
.envis gitignored and must never be committed.
🔁 Secret rotation
scripts/rotate-secrets.sh regenerates secrets in .env and restarts only the affected containers.
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:
crontab -e
# 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):
docker network create backbone
2. Create the tuwunel data volume:
docker volume create matrix_tuwunel-data
Start the stack
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
handleblocks. Do not mix bareresponddirectives with ahandle {}catch-all in the same site block — Caddy'shandle {}will intercept all requests before a barerespondcan 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.
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.
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.
matrix.your.domain:8448 {
reverse_proxy tuwunel:6167
}
# 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:
extra_hosts:
- "host.docker.internal:host-gateway"
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_TRANSPORTerrors on clients.
📋 Network topology
┌─────────────────────────────┐
│ backbone network │
│ │
Client ──HTTPS──▶ │ Caddy ──▶ tuwunel:6167 │
│ ──▶ lk-jwt-service │
│ ──▶ host.docker.internal:7880 (livekit)
└─────────────────────────────┘
host network (UDP): livekit (ports 7880, 7881, 50000-60000)
coturn (ports 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
coturnblocks RFC1918 ranges inturnserver.confto 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:
-
Drop your certificates into
coturn/certs/:coturn/certs/fullchain.pem coturn/certs/privkey.pemCerts must be valid for
turn.DOMAIN_NAME. If you have a wildcard cert (*.yourdomain.com) it will work as-is. -
Uncomment the TLS lines in
coturn/turnserver.conf:tls-listening-port=5349 cert=/etc/coturn/certs/fullchain.pem pkey=/etc/coturn/certs/privkey.pem -
Restart coturn:
docker compose up -d coturn
coturn/certs/is gitignored — your private key will never be committed.