Files
admin d33fede751 Enhance README with network diagram and details
Added network diagram and updated network topology information.
2026-04-06 08:11:30 -04:00

8.6 KiB
Raw Permalink Blame History

🧩 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

⚠️ .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 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 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.

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_TRANSPORT errors on clients.


📋 Network topology

NetworkDiagram

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, 5000060000 |
| coturn | 3478, 5349, 4915265535 |


📁 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:

    docker compose up -d coturn
    

coturn/certs/ is gitignored — your private key will never be committed.