7.4 KiB
Matrix Self-Hosted Stack — Setup Guide
File Overview
matrix-stack/
├── docker-compose.yml # All services: Continuwuity, Traefik, Coturn, LiveKit, lk-jwt-service
├── continuwuity.toml # Homeserver config: TURN URIs, LiveKit foci
├── livekit.yaml # LiveKit media server config
├── coturn.conf # TURN/STUN server config
└── README.md # This file
Prerequisites
-
Two DNS A records pointing to your server's public IP:
matrix.example.comlivekit.example.com(If you want your Matrix IDs as@user:example.comrather than@user:matrix.example.com, also pointexample.comto your server.)
-
Docker + Docker Compose installed.
-
Create the external proxy network Traefik uses:
docker network create proxy
One-Time Secret Generation
Coturn secret
# Install pwgen if needed: apt install pwgen
pwgen -s 64 1
Paste the output into both coturn.conf (static-auth-secret)
and continuwuity.toml (turn_secret). They MUST match.
LiveKit keys
docker run --rm livekit/livekit-server:latest generate-keys
This outputs a key (~20 chars) and secret (~64 chars). Paste them into:
livekit.yaml→ keys sectiondocker-compose.yml→ lk-jwt-service LIVEKIT_KEY / LIVEKIT_SECRET
Registration token (for invite-only signup)
pwgen -s 32 1
Paste into docker-compose.yml → CONTINUWUITY_REGISTRATION_TOKEN.
Starting Up
# First boot — creates admin user automatically
docker compose up -d
# Get your admin password
docker compose logs homeserver | grep "Created user"
# IMPORTANT: Edit docker-compose.yml and remove the --execute flag
# from the homeserver command, then restart:
docker compose up -d homeserver
Log in with any Matrix client (Element, Cinny, etc.) using
@admin:example.com and the generated password.
Firewall Rules
Open these ports on your server's firewall (ufw examples shown).
HTTP/HTTPS — handled by Traefik
ufw allow 80/tcp # HTTP (redirected to HTTPS by Traefik)
ufw allow 443/tcp # HTTPS for matrix.example.com and livekit.example.com
Coturn — TURN/STUN (raw UDP/TCP, NOT proxied by Traefik)
ufw allow 3478/tcp # STUN + TURN
ufw allow 3478/udp
ufw allow 5349/tcp # TURN over TLS (if you configured TLS in coturn.conf)
ufw allow 5349/udp
ufw allow 50201:65535/udp # Coturn media relay port range
LiveKit — RTC media (raw UDP/TCP, NOT proxied by Traefik)
ufw allow 7881/tcp # LiveKit direct TCP (for clients that can't UDP)
ufw allow 50100:50200/udp # LiveKit media relay port range
Summary table
| Port(s) | Protocol | Service | Via Traefik? |
|---|---|---|---|
| 80 | TCP | HTTP (→ HTTPS) | Yes |
| 443 | TCP | HTTPS | Yes |
| 3478 | TCP + UDP | Coturn TURN/STUN | No — direct |
| 5349 | TCP + UDP | Coturn TURN TLS | No — direct |
| 7881 | TCP | LiveKit direct TCP | No — direct |
| 50100–50200 | UDP | LiveKit media | No — direct |
| 50201–65535 | UDP | Coturn media relay | No — direct |
Key point: Traefik only handles ports 80 and 443. The Matrix homeserver (6167) and LiveKit HTTP (7880) are never exposed directly — Traefik proxies them internally. Coturn and LiveKit's RTC ports bypass Traefik entirely and are opened directly to the internet.
Can I use an External NGINX Proxy Manager instead of Traefik?
Short answer: yes, but with important caveats.
What NPM can handle
matrix.example.com→ proxy tohttp://YOUR_SERVER_IP:6167- Enable WebSockets
- Set
X-Forwarded-For,Host,X-Real-IPheaders
livekit.example.comwith path splitting:/sfu/get,/healthz,/get_token→http://YOUR_SERVER_IP:8081- Everything else →
http://YOUR_SERVER_IP:7880 - Enable WebSockets on both
- NPM handles Let's Encrypt TLS automatically
What NPM cannot handle
- Coturn — raw UDP/TCP, not HTTP. NPM (like Traefik) can't proxy it. You just open ports 3478/5349 directly and point DNS at your server IP.
- LiveKit RTC ports (7881/tcp, 50100-50200/udp) — same story. These bypass any reverse proxy entirely.
Changes needed to docker-compose.yml for NPM
-
Remove the entire
traefik:service block. -
Remove all
labels:blocks from homeserver and lk-jwt-service. -
Expose the ports NPM needs to reach:
homeserver: ports: - "127.0.0.1:6167:6167" # NPM proxies this lk-jwt-service: ports: - "127.0.0.1:8081:8081" # NPM proxies this # livekit already uses network_mode: host, so 7880 is available # on the host at 127.0.0.1:7880 automaticallyUsing
127.0.0.1:prefix means only NPM (on the same host or your network) can reach them — not the open internet. -
Remove the
acme:volume (NPM handles TLS). -
In NPM, configure two proxy hosts:
matrix.example.com
- Forward to:
http://YOUR_SERVER_IP:6167 - Websockets: ON
- Custom nginx config:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host;
livekit.example.com
- Forward to:
http://YOUR_SERVER_IP:7880(default) - Websockets: ON
- Advanced tab — add this custom config for path splitting:
location ~ ^/(sfu/get|healthz|get_token) { proxy_pass http://YOUR_SERVER_IP:8081$request_uri; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $http_host; proxy_buffering off; }
- Forward to:
NPM on a separate machine?
If NPM runs on a different server, replace 127.0.0.1 with your
Matrix server's internal IP, and make sure port 6167 is firewalled
to only allow connections from NPM's IP.
Verification
Test TURN credentials
curl "https://matrix.example.com/_matrix/client/r0/voip/turnServer" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" | jq
Should return a JSON object with uris, username, password.
Test TURN connectivity
- Go to https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
- Paste the credentials from above
- Click "Gather candidates" — look for
relaytype candidates
Test LiveKit
- GET
https://livekit.example.com/healthz— should return 200 - Use https://livekit.io/connection-test with a token from
/get_token
Test federation
https://federationtester.matrix.org — enter your domain
Troubleshooting
Federation not working
- Check
.well-known/matrix/serveris reachable:curl https://example.com/.well-known/matrix/server - Should return:
{"m.server":"matrix.example.com:443"}
TURN not working
- Verify firewall allows 3478/udp from the internet
- Check coturn logs:
docker compose logs coturn - Confirm
turn_secretin continuwuity.toml matchesstatic-auth-secretin coturn.conf
Element Call / group calls failing
- Check lk-jwt-service is reachable:
curl https://livekit.example.com/healthz - Confirm LiveKit UDP ports (50100-50200) are open
- Check
fociURL in continuwuity.toml matches where lk-jwt-service is deployed