Production-ready Matrix chat and group video platform using Docker Compose with Tuwunel (Rust-based Matrix homeserver), Caddy (reverse proxy with automatic HTTPS), LiveKit (SFU for group video/voice via Element Call), and Coturn (TURN relay for 1:1 WebRTC calls).
Mermaid source (click to expand)
graph TB
subgraph Internet["Internet"]
EC["Element Desktop / Web"]
FED["Federated Matrix Servers"]
end
subgraph Server["Server — YOUR_DOMAIN"]
subgraph Bridge["Docker Bridge Network"]
CADDY["Caddy<br/>Reverse Proxy + Auto TLS (LE)<br/>:443 · :8448"]
TW["Tuwunel<br/>Matrix Homeserver<br/>:8008<br/>/_synapse/* IP-restricted"]
EL["Element Web<br/>:80"]
JWT["lk-jwt-service<br/>:8080"]
WK[".well-known<br/>static JSON"]
end
subgraph Host["Host Network"]
LK["LiveKit SFU<br/>:7880 API ‹Docker only›<br/>:7881 TCP fallback<br/>:50000–60000 UDP"]
CT["Coturn<br/>:3478 TURN ‹no-stun›<br/>:5349 TURNS<br/>:60001–65535 UDP relay"]
end
end
EC -- "HTTPS :443" --> CADDY
FED -- ":8448 Federation" --> CADDY
CADDY -- "/_matrix/*" --> TW
CADDY -- "/* fallback" --> EL
CADDY -- "/lk-jwt/*" --> JWT
CADDY -- "/livekit-sfu/*<br/>host.docker.internal:7880" --> LK
CADDY -- "/.well-known/*" --> WK
JWT -. "YOUR_DOMAIN via host-gateway<br/>OpenID + CreateRoom" .-> CADDY
EC -. "Group calls · UDP :50000–60000" .-> LK
EC -. "1:1 calls · TURN relay :3478" .-> CT
Client ─── HTTPS :443 ───► Caddy ─┬─ /_matrix/* ──► Tuwunel :8008
├─ /_synapse/* ──► Tuwunel :8008 (server IPs only)
├─ /lk-jwt/* ──► lk-jwt-service :8080
├─ /livekit-sfu/* ──► LiveKit :7880 (host)
├─ /.well-known/* ──► static files
└─ /* ──► Element Web :80
Fed ─── HTTPS :8448 ──► Caddy ─── /_matrix/* ──► Tuwunel :8008
1:1 calls Element ··· WebRTC P2P ··· Coturn TURN/TURNS relay (:3478 / :5349)
Group calls Element ──► lk-jwt ──► LiveKit SFU (:50000-60000 UDP)
| From | To | Via | Purpose |
|---|---|---|---|
| Client | Caddy :443 | HTTPS | All API, web UI, JWT, SFU signaling |
| Fed servers | Caddy :8448 | HTTPS | Matrix federation |
| Caddy | Tuwunel :8008 | Bridge | /_matrix/* /_synapse/* |
| Caddy | Element Web :80 | Bridge | /* (fallback) |
| Caddy | lk-jwt :8080 | Bridge | /lk-jwt/* |
| Caddy | LiveKit :7880 | host.docker.internal | /livekit-sfu/* |
| Caddy | .well-known files | File server | /.well-known/* |
| lk-jwt | Caddy | host-gateway → :443 | OpenID validation + CreateRoom |
| Client | LiveKit | Direct UDP | Group call media :50000-60000 |
| Client | Coturn | Direct UDP/TCP | 1:1 call TURN relay :3478 / TURNS :5349 |
SSO / native OIDC: SSO is off by default (
keycloak_enabled: false). When enabled, Tuwunel's native OIDC server activates and the Caddyfile routes/.well-known/openid-configurationand/_tuwunel/oidc/*to Tuwunel ahead of the static/.well-known/*file server (first-match-wins). See docs/keycloak-integration.md.
- STUN disabled (
no-stunin coturn) — prevents UDP amplification attacks - TURNS (TLS) on :5349 — encrypted TURN relay using Caddy's Let's Encrypt certs
/_synapse/admin API restricted to server IPs only (127.0.0.1, ::1, server public IPs)- CORS narrowed to
/_matrix/*and/.well-known/*paths only - LiveKit API (7880) only reachable from Docker bridge network (172.16.0.0/12)
- Registration requires a token (not open)
- All secrets file-mounted with 600 permissions
- HTTPS-only via Caddy auto-TLS (Let's Encrypt)
- Docker log rotation —
json-filedriver with size caps to prevent disk fill - Weekly coturn restart cron for TLS cert renewal
- Linux server (Ubuntu 24.04 LTS recommended) with a public IPv4 address
(IPv6 is optional — the defaults are IPv4-only;
matrix_server_ipv6is commented out in the Ansible vars and coturn ships without an IPv6external-ip) - Root or sudo access
- A domain name with a DNS A record pointing to your server (add an AAAA record only if you enable IPv6)
- Ports open to the internet: 80, 443, 8448, 3478, 5349, 50000-60000, 60001-65535
- At least 2 GB RAM and 10 GB disk space
Run these checks before setup.sh or ansible-playbook:
# 1) DNS resolution should already match your server
dig +short YOUR_DOMAIN A
dig +short YOUR_DOMAIN AAAA
# 2) Required public ports must not be occupied by another service
sudo ss -lntup | grep -E ':(80|443|8448|3478|5349|7881)\b'
# 3) Server IP sanity
curl -4 ifconfig.me
curl -6 ifconfig.meIf you do not use IPv6, remove IPv6-specific values consistently in all config
files instead of leaving YOUR_IPV6 placeholders.
All configuration uses placeholders that must be customized before deployment. Edit these files:
setup.sh(the script also hasDOMAIN,SERVER_IP_V4,SERVER_IP_V6, andADMIN_EMAILvariables)Caddyfile,docker-compose.yml,tuwunel.toml,coturn.conf,livekit.yaml,element-config.jsonansible/inventory/hosts.ymlandansible/inventory/group_vars/matrix_servers.yml(if using Ansible)
config.env.example is a reference template only. It is not auto-loaded by
setup.sh or update.sh.
- Edit all files and replace placeholders:
# Replace YOUR_DOMAIN, YOUR_IPV4, YOUR_IPV6, YOUR_EMAIL throughout:
find . -type f \( -name "*.yml" -o -name "*.conf" -o -name "*.toml" -o -name "Caddyfile" -o -name "*config.json" \) \
-exec sed -i 's/YOUR_DOMAIN/example.com/g' {} \;
sed -i 's/YOUR_IPV4/1.2.3.4/g' $(find . -type f)
sed -i 's/YOUR_IPV6/2001:db8::1/g' $(find . -type f)
sed -i 's/YOUR_EMAIL/admin@example.com/g' $(find . -type f)- Deploy:
sudo ./setup.sh- Verify deployment with the checks in the Health Checks section below.
- Edit Ansible variables:
# ansible/inventory/hosts.yml — set your server host, IP, SSH user
# ansible/inventory/group_vars/matrix_servers.yml — set domain, IPs, email- Deploy:
cd ansible
ansible-playbook site.yml- Common rerun patterns:
# Re-render templates only (requires secrets facts to be available in the run)
ansible-playbook site.yml --tags secrets,config
# Restart and health checks only
ansible-playbook site.yml --tags deploy- Installs dependencies and Docker
- Generates and preserves Matrix/LIVEKIT/TURN secrets
- Renders all major config files from templates
- Applies UFW firewall rules
- Deploys and health-checks Docker services
- Installs weekly coturn restart cron for TLS certificate refresh
Still manual:
- DNS setup (A/AAAA and optional federation SRV)
- External Keycloak realm/client provisioning (if enabled)
# 1. Copy files to the server
scp -r ./* user@your-server:/tmp/matrix-deploy/
# 2. SSH in and prepare
ssh user@your-server
sudo mkdir -p /opt/matrix
sudo cp -r /tmp/matrix-deploy/* /opt/matrix/
cd /opt/matrix
# 3. Parameterize (replace YOUR_* placeholders)
sudo sed -i 's/YOUR_DOMAIN/example.com/g' ./* ./ansible/inventory/*.yml
sudo sed -i 's/YOUR_IPV4/1.2.3.4/g' ./* ./ansible/inventory/*.yml
# 4. Make scripts executable
sudo chmod +x setup.sh firewall.sh update.sh verify-config.sh
# 5. Deploy
sudo ./setup.sh| Service | Image | Network | Purpose |
|---|---|---|---|
| Tuwunel | ghcr.io/matrix-construct/tuwunel:latest |
Bridge | Matrix homeserver (Rust) |
| Caddy | caddy:latest |
Bridge | Reverse proxy, auto-TLS |
| Element Web | vectorim/element-web:latest |
Bridge | Matrix web client |
| lk-jwt-service | ghcr.io/element-hq/lk-jwt-service:latest |
Bridge | Matrix auth → LiveKit JWT |
| LiveKit | livekit/livekit-server:latest |
Host | SFU for group video/voice |
| Coturn | coturn/coturn:latest |
Host | TURN relay for 1:1 calls |
| Port | Protocol | Access | Service |
|---|---|---|---|
| 80 | TCP | Public | Caddy HTTP → HTTPS redirect |
| 443 | TCP+UDP | Public | Caddy HTTPS + QUIC |
| 8448 | TCP | Public | Matrix Federation |
| 3478 | TCP+UDP | Public | Coturn TURN (STUN disabled) |
| 5349 | TCP+UDP | Public | Coturn TURNS (TLS) |
| 7880 | TCP | Docker only | LiveKit API/WebSocket |
| 7881 | TCP | Public | LiveKit WebRTC TCP fallback |
| 50000-60000 | UDP | Public | LiveKit SFU media |
| 60001-65535 | UDP | Public | Coturn relay ports |
/opt/matrix/
├── docker-compose.yml # Docker services
├── tuwunel.toml # Matrix homeserver config
├── Caddyfile # Reverse proxy config
├── livekit.yaml # LiveKit SFU config
├── coturn.conf # TURN server config (no-stun)
├── element-config.json # Element Web client config
├── tuwunel.example.toml # Upstream Tuwunel reference config
├── config.env.example # Reference env template (not auto-loaded)
├── setup.sh # Initial server setup
├── update.sh # Safe config update with backup
├── firewall.sh # UFW firewall management
├── verify-config.sh # Pre-deploy config validator
├── check-turn-config.sh # TURN config runtime check
├── test-turn.sh # TURN relay allocation test
├── test-stun-check.py # STUN-hardening check (expects no response)
├── turn_shared_secret # Generated TURN secret (600)
├── registration_token # Generated registration token (600)
├── livekit_api_key # Generated LiveKit API key (600)
├── livekit_api_secret # Generated LiveKit API secret (600)
├── wellknown/
│ └── matrix/
│ ├── server # Federation delegation
│ └── client # Client discovery + rtc_foci
├── docs/
│ └── keycloak-integration.md # External Keycloak SSO guide
├── TODO.md # Roadmap / known limitations
└── ansible/ # Ansible automation
├── site.yml # Main playbook
├── inventory/
│ ├── hosts.yml
│ └── group_vars/
│ └── matrix_servers.yml
└── roles/
├── common/ # System packages
├── docker/ # Docker install
├── secrets/ # Secret generation
├── firewall/ # UFW rules
├── matrix_config/ # Template rendering
└── deploy/ # Docker Compose deploy
cd /opt/matrix
docker compose logs -f # All services
docker compose logs -f tuwunel # Homeserver
docker compose logs -f caddy # Reverse proxy
docker compose logs -f livekit # SFU
docker compose logs -f livekit-jwt # JWT service
docker compose logs -f coturn # TURN serverdocker compose up -d # Start
docker compose down # Stop
docker compose restart # Restart all
docker compose ps # Status
docker compose pull # Update imagesUse update.sh when replacing config files from a newer repository revision:
cd /opt/matrix
sudo ./update.shupdate.sh creates a timestamped backup under /opt/matrix/backups/,
preserves existing secrets when present, regenerates missing secrets, validates
configuration, then restarts services.
By default, reruns preserve existing secrets. To rotate a specific secret, replace the corresponding file and redeploy:
turn_shared_secretregistration_tokenlivekit_api_keylivekit_api_secret
After rotation, restart affected services (tuwunel, coturn, livekit,
livekit-jwt) or run docker compose up -d.
sudo ./firewall.sh # Apply all rules
sudo ./firewall.sh status # Show current rules
sudo ./firewall.sh check # Verify required rulescurl https://YOUR_DOMAIN/_matrix/client/versions | jq
curl https://YOUR_DOMAIN/_matrix/federation/v1/version | jq
curl https://YOUR_DOMAIN/.well-known/matrix/server | jq
curl https://YOUR_DOMAIN/.well-known/matrix/client | jqRun these in order after first deploy or after major config changes:
cd /opt/matrix
# Static config sanity
sudo ./verify-config.sh
# Firewall rule validation
sudo ./firewall.sh check
# TURN runtime checks
sudo ./check-turn-config.sh
sudo ./test-turn.sh
# STUN hardening check (expect no STUN response)
python3 ./test-stun-check.pyElement Call uses LiveKit as the SFU for group calls. The call flow:
- Element discovers LiveKit via
.well-known/matrix/client→rtc_foci - Element requests a JWT from
lk-jwt-service(authenticated via Matrix OpenID) lk-jwt-servicecreates a room on LiveKit and returns the JWT- Element connects to LiveKit SFU for media
use_exclusively: true in element-config.json forces all calls through
Element Call (both 1:1 and group). Remove it to use legacy VoIP for 1:1 calls.
LIVEKIT_FULL_ACCESS_HOMESERVERS (set on livekit-jwt in docker-compose.yml,
via matrix_domain in the Ansible template) lists which homeservers'
users are allowed to create LiveKit rooms rather than only join existing
ones. It's set to this server's own domain so local users can start group
calls; federated users from other homeservers can still join a call already
in progress.
Coturn provides authenticated TURN relay for NAT traversal. STUN is disabled
(no-stun) to prevent UDP amplification attacks (Shadowserver CVE). Clients
still get mapped-address discovery through TURN allocations.
All secrets are auto-generated by setup.sh (or Ansible secrets role):
- TURN secret — shared between Tuwunel and Coturn via file mount
- Registration token — required for new user signup
- LiveKit API key/secret — used by lk-jwt-service and LiveKit SFU
The Keycloak client secret is not auto-generated — it is supplied via the
keycloak_client_secret Ansible variable (or a mounted file for manual setups).
See docs/keycloak-integration.md.
new_user_displayname_suffix = "" in tuwunel.toml suppresses Tuwunel's
default 💕 suffix on new users' display names. Set a value to restore it.
To register a new user, use a Matrix client (like Element) with:
- Homeserver: https://YOUR_DOMAIN
- Registration Token: (found in
/opt/matrix/registration_token)
Or use the command line:
# Using curl to register (replace USERNAME, PASSWORD, and TOKEN)
curl -X POST "https://YOUR_DOMAIN/_matrix/client/r0/register" \
-H "Content-Type: application/json" \
-d '{
"username": "yourname",
"password": "yourpassword",
"auth": {
"type": "m.login.registration_token",
"token": "YOUR_REGISTRATION_TOKEN"
}
}'Check if your server is visible to the Matrix federation:
# From any machine
curl https://federationtester.matrix.org/api/report?server_name=YOUR_DOMAIN# Check Docker logs
docker compose logs
# Ensure ports aren't already in use
netstat -tulpn | grep -E ":(80|443|3478|5349|8448)"
# Restart Docker
systemctl restart docker
docker compose up -d# Check Caddy logs
docker compose logs caddy
# Ensure DNS records are correct
dig YOUR_DOMAIN A
dig YOUR_DOMAIN AAAA
# Verify port 80 is accessible (required for Let's Encrypt)
curl -I http://YOUR_DOMAIN# Check coturn logs
docker compose logs coturn
# Verify UDP ports are open
nc -u -v YOUR_DOMAIN 3478
# Check firewall
ufw status# Check LiveKit is running
docker compose logs livekit
docker compose logs livekit-jwt
# Verify LiveKit WebSocket proxy through Caddy
curl -i https://YOUR_DOMAIN/livekit-sfu/
# Verify JWT service is reachable
curl -i https://YOUR_DOMAIN/lk-jwt/
# Check well-known includes rtc_foci
curl -s https://YOUR_DOMAIN/.well-known/matrix/client | jq '.["org.matrix.msc4143.rtc_foci"]'
# Watch LiveKit logs during a call
docker compose logs -f livekit# Check federation port
curl https://YOUR_DOMAIN:8448/_matrix/federation/v1/version
# Verify SRV record (optional but recommended)
dig _matrix._tcp.YOUR_DOMAIN SRV- Keycloak SSO Integration: docs/keycloak-integration.md
- Tuwunel: https://github.com/matrix-construct/tuwunel
- LiveKit: https://docs.livekit.io/realtime/self-hosting/deployment/
- Element Call: https://github.com/element-hq/element-call
- lk-jwt-service: https://github.com/element-hq/lk-jwt-service
- Matrix Spec: https://spec.matrix.org/
- Caddy: https://caddyserver.com/docs/
- Coturn: https://github.com/coturn/coturn
- Docker Compose: https://docs.docker.com/compose/
- Matrix Homeserver Admin Room: #tuwunel:matrix.org
- Matrix Spec: https://matrix.org/docs/
- Federation Tester: https://federationtester.matrix.org/
This setup configuration is licensed under GPL-3.0. See LICENSE file for details.
Configure YOUR_DOMAIN, YOUR_IPV4, YOUR_IPV6, and YOUR_EMAIL in all
configuration files before deployment.