Matrix chat with Helm
The chart can deploy the Matrix chat infrastructure: a Tuwunel homeserver and a LiveKit media SFU for voice and video calls. Waldur's API issues the LiveKit call tokens itself, and only to joined members of the room (see Calls).
Enabling
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
There is no registration token to invent, no LoadBalancer IP to copy back in
afterwards, and no Setup wizard to run — see
Zero-touch setup for what the chart does instead. The chart
also turns on the project.show_matrix_chat feature flag, without which Waldur
shows no chat drawer, project chat tab or Matrix admin page; set it in
waldur.featureFlags to override that.
livekit.keys (apiKey + apiSecret) supports existingSecret for external
secret managers, as does the generated token Secret
(matrixChat.setup.existingSecret).
Zero-touch setup
The appservice as_token is a shared secret between two programs — Waldur and
the homeserver. Nobody expects Postgres to boot, generate its own password, and
show a wizard so you can paste it into Django's settings; the deployment picks
the password and hands the same value to both sides. matrixChat.setup (on by
default) treats the Matrix tokens the same way.
On install, a pre-install/PreSync Job mints an as_token, an hs_token, a
registration token and a bootstrap password into the matrix-appservice-secret
Secret. The homeserver mounts that Secret directly — which is also what stops it
starting before the Secret exists — and a second post-install/PostSync Job
writes the tokens into the backend's Constance settings with mastermind's
waldur init_matrix_settings. Neither Job ever prints token material; secrets are
logged only as sha256:<first-12-hex> fingerprints. The bootstrap password is
never written to Constance; only the registration step reads it.
Upgrades never rotate the tokens. The generating Job reads the existing
Secret and reuses it; it holds get and create on Secrets and deliberately not
update or delete, so it structurally cannot rotate a live token. A
registration carries exactly one as_token, so rotation is an unavoidable
cutover — if it happened behind your back the bot would silently start failing
with M_UNKNOWN_TOKEN. The Secret also survives helm uninstall, because Helm
never owned it: the Job creates it through the API rather than the chart
rendering it, and it carries no ArgoCD tracking label, so ArgoCD does not prune
it either. That matters, since losing these tokens while the homeserver's data
volume survives means losing the ability to talk to your own chat history.
Why the Jobs use the Kubernetes API rather than generating the tokens in a
template with randAlphaNum guarded by lookup: this chart ships
applicationset.yaml, so production runs on ArgoCD, where lookup returns empty
during server-side dry-run and diff rendering. ArgoCD would see a fresh random
token on every diff, report permanent drift, and rotate the token on an unrelated
sync. The Jobs run at sync time against a real API server instead, with a Role
scoped to one namespace.
That Role names what it touches wherever the verb allows: get on the setup
Secret and create on Secrets (a create cannot be limited by name), and only
the generating Job uses it. The wiring Job runs with no API token, and with
existingSecret the chart renders no Role at all. LiveKit's node-IP discovery runs under a ServiceAccount of its
own, livekit-node-ip, which can read the livekit-rtc Service and nothing
else. Only the discovery initContainer gets its token, so the internet-facing
media server holds none.
To manage the tokens yourself (Vault, Sealed Secrets, External Secrets
Operator), point matrixChat.setup.existingSecret.name at a Secret with
as_token, hs_token and registration_token keys — nothing is then
generated. Also include bootstrap_password: the registration step creates the
bootstrap admin with it on a new homeserver and signs in with it later. Without
it the first registration fails unless the Secret has an admin_token: the
command creates no bootstrap admin that nobody could sign in as. Each key name
is configurable under existingSecret.
The registration step needs admin rights to register or replace the
registration: on a new homeserver, after a rotation, and whenever the
homeserver's copy differs from Waldur's. It uses an optional admin_token key
(existingSecret.adminTokenKey) when the Secret has one: a homeserver admin's
access token, handed to the Job as MATRIX_ADMIN_TOKEN. Otherwise it signs in
as the bootstrap admin. The chart never generates admin_token. Add it for the
sync that needs it and remove it afterwards: the Job uses it ahead of the
bootstrap password, so a revoked token left there makes the next change fail.
While the chart seeds the tokens, the Setup wizard in Waldur refuses to rotate
them (it answers 409): the next sync would write the old ones back. It knows
from the MATRIX_TOKENS_MANAGED_BY setting, which the wiring Job sets to
deployment on every run. The other way round, init_matrix_settings refuses
appservice tokens in Constance that the deployment did not seed, for example
from a run of the Setup wizard while setup was off: the wiring Job then fails
and writes nothing. To hand the tokens to the deployment, clear
MATRIX_APPSERVICE_AS_TOKEN and MATRIX_APPSERVICE_HS_TOKEN under
Administration → Configuration → Matrix chat → Settings and sync again,
which registers the appservice with the Secret's tokens.
Opting out
To manage the tokens by hand again:
- Set
matrixChat.setup.enabled: falseand supply the registration token again. Pointinghomeserver.registrationTokenExistingSecretatmatrix-appservice-secret(keyregistration_token) keeps the homeserver on the token it already has. LiveKit's node-IP discovery does not depend on setup and keeps working. - Under Administration → Configuration → Matrix chat → Settings, clear
"Matrix Tokens Managed By" (
MATRIX_TOKENS_MANAGED_BY). Nothing clears it for you: the chart stops running the wiring Job but leaves Constance as it is, and while the setting saysdeploymentthe Setup wizard keeps refusing to rotate the tokens. - Make sure the homeserver has an admin. Registering the appservice by hand,
with
!admin appservices registerin#admins:<serverName>, needs one, and so does every token rotation. A homeserver that setup created already has@waldur-bootstrap, whose password stays inmatrix-appservice-secretunderbootstrap_password. A new homeserver with setup off has none: nothing creates the bootstrap admin, and the first account to register does not become an admin (grant_admin_to_first_user = false), because Waldur registers an account for whoever opens the chat first.
Register a temporary admin through the shared-secret API instead, as
Rotating with password login off
describes. The snippets read the secret from Waldur's
MATRIX_USER_REGISTRATION_SECRET, which has to be the registration token
that step 1 gives the homeserver: the chart sets the homeserver's
registration_shared_secret to that token with setup on or off. The
registration answers with the admin's access token, which works with
password login off too. Hand it to register_matrix_appservice as
MATRIX_ADMIN_TOKEN, as under
Runtime steps, then have the
admin deactivate itself.
The generated Secret stays where it is: Helm never owned it, so neither turning
setup off nor helm uninstall deletes it.
Rotating the tokens
Rotation is a deployment operation:
- Put new values for
as_tokenandhs_tokeninto the Secret, always both. Leaveregistration_tokenandbootstrap_passwordas they are:
1 2 | |
- Run
helm upgrade(or sync in ArgoCD). The wiring Job seeds the new tokens into Constance, and withregisterAppserviceon (the default) it signs in withadmin_tokenif the Secret has one, and as@waldur-bootstrapotherwise, unregisters the old registration and registers the new one. Tuwunel does not replace a registration that is registered again under the same id, which is why the old one is removed first. With it off, chat is down from this sync until you register the new tokens by hand. - The Job fails if the homeserver still rejects the new
as_tokenafterwards.
Nothing reads the new values until step 2, so chat keeps working on the old tokens until then; it is down only for the seconds between the Job seeding the new tokens and replacing the registration. Events sent in those seconds, such as a bot command, are not delivered to Waldur.
Step 1 changes both because either may have leaked. A new hs_token alone is
detected too: the Job compares the homeserver's copy of the registration with
Waldur's.
Do not delete the Secret to rotate: the Job would also mint a new bootstrap password, which no longer matches the bootstrap user on the homeserver, and the registration step would fail.
Treat the as_token as a homeserver admin credential. Waldur's registration
claims every local user ID, so from the moment the appservice is registered the
token can act as any account on the homeserver, @waldur-bootstrap and the
other admins included. The registration step also makes the bot a homeserver
admin, so the token can call the admin API as the bot too, for example to set
any account's password. Waldur staff can read it in clear through the settings
API. See
Making the bot a homeserver admin.
The tokens also end up outside the Secret. Each registration is posted to
#admins:<serverName> as a plain message, both tokens included, and stays in
that room's history on the homeserver's PVC; a rotation makes the old ones
useless. Tuwunel 1.9.3 sends the hs_token in the URL of every call to Waldur,
and when a call cannot reach Waldur's API its warning quotes that URL, so the
token lands in the homeserver's log. Treat those logs as secret, and rotate both
tokens if they were shipped anywhere less trusted than the cluster.
The registration token rotates separately, and needs a restart:
- Put a new
registration_tokeninto the Secret. - Run
helm upgrade(or sync). The wiring Job seeds it into the backend'sMATRIX_USER_REGISTRATION_SECRET. - Restart the homeserver, which reads the token from the Secret, as its
registration token and its
registration_shared_secret, in environment variables at pod start. Nothing in the chart restarts it when the Secret changes: the Job generates the Secret, so the pod's checksum of the chart values cannot see it.
1 | |
Until step 3 the homeserver still holds the old token while Waldur offers the new one, so run the two back to back. Existing users and rooms are not affected.
With setup.existingSecret the Secret belongs to your secret manager. Rotate
the values there and wait for it to sync the Secret. Then run helm upgrade (or
sync) as above, and restart the homeserver if the registration token changed.
Rotating with password login off
With homeserver.loginWithPassword: false, as with single sign-on, the
bootstrap admin cannot sign in. A sync with working tokens then cannot read the
homeserver's copy of the registration, so it only warns and changes nothing,
and step 2 of a rotation fails after seeding the new tokens, with "Password
login is disabled on the homeserver". For a rotation, or to apply a changed
registration, either set loginWithPassword: true for that sync and back to
false afterwards (the homeserver restarts on each change), or put an
admin_token into the Secret, along with the new tokens in step 1 of a
rotation, and remove it after the sync.
To get a token, register a temporary admin with the two snippets under Token rotation in the setup guide, and after the sync have it deactivate itself. They read the homeserver URL and the registration secret from Waldur's settings, so run them in the API pod, which reaches the homeserver's admin API inside the cluster; nothing outside does. The token goes into the Secret through standard input, never on a command line:
1 2 3 4 | |
After the sync, remove admin_token from the Secret and have the admin
deactivate itself:
1 2 3 4 5 | |
Calls need encrypted transports. With livekit.publicUrl set, the chart
refuses to render unless publicUrl is a wss:// URL and apiScheme is
https: clients send their Matrix OpenID token to Waldur's call token API and
their LiveKit token on the signalling websocket, and both are bearer
credentials. On a local or kind cluster without TLS, set
matrixChat.livekit.allowInsecureTransport: true to allow ws:// and http.
Chat has no such check, but needs HTTPS just as much: Waldur's session
response carries the user's Matrix tokens and recovery key. With the defaults,
apiScheme: "http" and ingress.tls.enabled: false, the chart's ingresses
serve it over plain HTTP. Set apiScheme: "https", and terminate TLS at the
ingresses or in front of them. TLS alone is not enough: homeport's API URL,
the homeserver's well-known client URL and MATRIX_HOMESERVER_PUBLIC_URL are
all built from apiScheme, so with http clients are still sent to http://
URLs.
Image pinning
The homeserver and LiveKit images are pinned to specific versions by default,
by tag — never latest:
- homeserver —
ghcr.io/matrix-construct/tuwunel, pinned to the single supported version (see below). Sethomeserver.imageDigestto pin immutably by digest. - livekit —
livekit/livekit-server, pinned by tag;livekit.imageDigestavailable. Pulled fromlivekit.imageRegistry(docker.ioby default) — its own key, notglobal.imageRegistry, so pointingglobalat a private mirror doesn't rewrite LiveKit to a registry that has no such image. Overridelivekit.imageRegistryif you mirror it.
A tag can be moved to another image; only a digest cannot. The homeserver pod's
render-config init container and the bot's check-db-ready run
postgres:17 (waldur.initdbImageName), a tag that follows every 17.x
release; mirror it if every image must stay fixed. The bot itself runs Waldur's
image, at waldur.imageDigest when that is set, else at waldur.imageTag.
Supported homeserver version
Waldur bundles the homeserver, so its version is ours to support, not yours to choose. One version is supported at a time, the same across both packaging paths:
| Path | Value |
|---|---|
| Helm | matrixChat.homeserver.imageTag |
| Docker Compose | WALDUR_TUWUNEL_IMAGE_TAG |
Both are v1.9.3. Do not set a version we do not ship, and do not let the two
diverge. Waldur's chat drawer is built around details of this version: for
example, it fetches the dehydrated device's queued events with the POST
Tuwunel serves, not the GET matrix-js-sdk sends.
Upgrading
Tuwunel migrates its embedded database in place on the first boot of a new version, before it opens its port. From 1.9.1 a long migration logs its phase and progress every fifteen seconds; earlier versions log nothing while it runs. Every minor release so far has done this, so read the upstream release notes before moving in either direction.
- Scale the homeserver StatefulSet to zero and back the PVC up (see Backup and restore).
- Bump
imageTagand runhelm upgrade --timeout 20m, longer thanmatrixChat.setup.homeserverWaitTimeoutplus a margin. With the default five minutes, a long migration marks the release failed while the wiring Job is still waiting. Do not add--atomic, and do nothelm rollbacka failed release: both point the tag back at the older Tuwunel, the downgrade warned about below. ArgoCD has no such limit. - Let the first boot finish. A pod that is slow to become ready is migrating,
not hung. The chart's
startupProbekeeps liveness off for up to six hours so Kubernetes does not kill it mid-migration, which corrupts the database. The wiring Job waitsmatrixChat.setup.homeserverWaitTimeout(15 minutes by default) for the homeserver before registering the appservice, then fails; Constance is seeded by then, and the next sync retries the registration. - Check
/_matrix/client/versions, then/api/admin/matrix/diagnostics/on the Waldur side.
Downgrades are the dangerous direction. An older Tuwunel starts cleanly on a migrated database and then silently serves stale data from the old stores. A successful downgrade boot means nothing. Roll back by restoring the snapshot, never by re-pointing the tag at an older image.
From 1.9.2, rooms are created as room version 12 by default. Existing rooms keep their version. In a version 12 room the creator, the Waldur bot, has the highest power level by definition and can never be listed in the room's power levels.
serverName is immutable: it is baked into every user and room ID. The
homeserver stamps it into the database and refuses to boot under another
name:
1 | |
Restore the original serverName; do not wipe the PVC, which is the chat
corpus.
CVE response
Tuwunel fixes security issues in its next release and does not backport them. Its security policy promises a GitHub security advisory with each fixing release, but it has published none so far, not even for the federation disclosure fixed in 1.9.0: the fixes are described only in the release notes. Watch the releases and read each one's notes for security fixes. Both the client-server and federation surfaces are exposed through the Matrix ingress.
- Fix in a patch release of the supported minor: bump both packaging paths and ship a chart patch release.
- Fix needs a minor or major jump: follow the upgrade procedure above, verify on a restored snapshot first, bump both paths together.
- No fixed release yet:
matrixChat.enabled=falseremoves the homeserver and its ingress; the PVC and its history are kept. Waldur keeps offering chat until you also switchMATRIX_ENABLEDoff under Administration → Configuration → Matrix chat → Settings and hide it withwaldur.featureFlags: {"project.show_matrix_chat": false}. To turn chat back on, setmatrixChat.enabled=true, remove thatfeatureFlagsentry and switchMATRIX_ENABLEDon again: the wiring Job switches it on only at the first seeding. To keep the switch in your values instead, setwaldur.settingsOverrides.MATRIX_ENABLED, which the chart applies on every sync.
Backup and restore
Back up these four together, with the bot and the homeserver stopped. Backup and restore in Waldur's admin guide says what each holds, why they must come from the same moment, and why a history export is not a backup.
| What | Holds |
|---|---|
PVC data-matrix-homeserver-0, or homeserver.persistence.existingClaim |
The homeserver's database, media included |
Secret matrix-appservice-secret, or setup.existingSecret |
The appservice and registration tokens, and the bootstrap password registration signs in with. It survives helm uninstall, but not the namespace |
| Waldur's database | Rooms and Matrix profiles, the tokens in Constance, users' escrowed recovery keys, the bot's keys (schema matrix_bot), and the history exports, files included: Waldur's default DatabaseStorage keeps them in the database, encrypted, each export's key in its row. See PostgreSQL backup configuration |
waldur.secretKey and waldur.fieldEncryptionKey, or their Secrets |
FIELD_ENCRYPTION_KEY, or a key derived from SECRET_KEY while it is unset (the chart's default), unlocks the recovery keys, the bot's keys, every history export and the secret Constance settings, tokens included; without it none of them can be read (see Components). During a key rotation, keep waldur.fieldEncryptionKeyFallbacks too, and restore it with the rest |
Taking a backup
Tuwunel 1.9 can also back its database up while it runs (database_backup_path
and !admin server backup-database), but the chart does not configure it, an
admin has to run the command in the admin room, and it leaves media out. A
copy of the volume taken while the homeserver is scaled to zero needs none of
that and includes media. Chat is down while it is taken; Waldur is not. Where
the storage class supports CSI volume snapshots, a VolumeSnapshot of the
stopped claim does the same job; otherwise copy the files out through a pod
that mounts the claim:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | |
The bot is stopped with the homeserver so that the keys in the database backup
match the homeserver's copy. For the same reason the scheduled
waldur-db-backup-cronjob runs are no substitute: they run while the bot is up,
so their dumps do not pair with a copy of the volume. Under ArgoCD, pause
automated sync first: self-heal scales both back up.
Keep the files off the cluster, and restrict access to them as to the database: together they hold every token and, with the homeserver's volume, the keys to every user's key backup, and so to every encrypted message.
History exports are encrypted and do not compress, so the database dumps grow by about as much as the exports take up.
LiveKit keeps no state: only its API key and secret matter (the
matrixChat.livekit.keys values or their Secret), and these are part of the
release's values or Secrets you already keep. If TURN is on, keep the Secret
named by matrixChat.livekit.turn.tls.existingSecret too, or let cert-manager
issue it again. Nothing of a call needs restoring, but calls in progress drop
while the homeserver is stopped, and none can start until it is back: Waldur
checks each caller at the homeserver before it issues a call token.
Restoring the homeserver
When the claim is lost or damaged, by a failed Tuwunel upgrade for example,
restore it. The StatefulSet adopts an existing claim named
data-matrix-homeserver-0 instead of creating an empty one, so create the claim
and fill it before the homeserver starts: on an empty volume Tuwunel creates a
new database, and the archive cannot be unpacked over that.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Start the copy pod from the backup step again, then:
1 2 3 4 5 6 | |
The registration comes back with the volume, so the wiring Job changes nothing on the next sync, unless the tokens were rotated after the backup: then it replaces the registration, as after a rotation.
Waldur's database is now newer than the homeserver.
Restoring only the homeserver
in Waldur's admin guide says what that breaks: deleting matrix_bot.synctokens
resets the bot's read position, without which it skips events; new chat
sessions may not reach the bot for a while; users and rooms created after the
backup are missing, which its repair snippet fixes; and members' key backups
roll back, which its last command refills. Run its waldur shell -c "…"
commands with kubectl exec deployment/waldur-mastermind-worker -- in front,
and the repair snippet with
kubectl exec -i deployment/waldur-mastermind-worker -- waldur shell <<'EOF'.
Restoring only Waldur's database to an older backup has the reverse effects:
see Restoring only Waldur's database.
Restoring everything
On a new cluster, or after losing the namespace, restore in this order, so that nothing starts on an empty volume or mints new tokens:
- Create the namespace and the Secret from the saved copy. The secrets Job then reuses the tokens instead of generating new ones:
1 2 | |
- Restore Waldur's database, and set the same
waldur.secretKeyandwaldur.fieldEncryptionKey, andwaldur.fieldEncryptionKeyFallbacksif a key rotation was under way. This order needs the database outside the release, as withexternalDB: the bundled PostgreSQL only exists once the chart is installed, and this guide does not cover restoring into it. - Create and fill
data-matrix-homeserver-0as above. helm install. The wiring Job finds the appservice already registered and changes nothing.
Losing the setup Secret
If matrix-appservice-secret is deleted while the homeserver keeps its data,
put it back before the next sync: the secrets Job would otherwise mint new
tokens that neither the homeserver nor Waldur holds. helm upgrade refuses to
render while the matrix-homeserver StatefulSet runs and the Secret is
missing. ArgoCD renders without access to the cluster, so the check cannot run
there: pause automated sync until the Secret is back.
Create it from the backup, as in step 1 above. Without a backup, rebuild it from Waldur's settings, which hold the same tokens. The values go from the API pod straight into the Secret, never through a command line or a file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
The bootstrap password is lost with the Secret, since Waldur never stores it,
and a new one would not match @waldur-bootstrap. Syncs keep working: the
wiring Job only warns that it could not compare the registration. The next
change to the registration, a token rotation say, needs an admin_token in the
Secret, as in Rotating with password login off.
Checking a restore
GET /api/admin/matrix/diagnostics/ (staff) passes the checks it passed before
the restore, history_exports among them with no failures. Then send !help in
a room: the bot answers.
Where history export is on, download the newest completed export
(GET /api/matrix/exports/?state=completed, then
GET /api/matrix/exports/<uuid>/download/export/). A 409 means the export
cannot be decrypted, most likely because the restored database does not match
the configured FIELD_ENCRYPTION_KEY (or SECRET_KEY, while that is unset):
then the recovery keys and the bot's keys cannot be read either.
Token lifetimes
Waldur's chat drawer signs in with a refresh token, so its access tokens expire and are renewed in the background:
| Tuwunel key | Helm value | Default |
|---|---|---|
access_token_ttl |
homeserver.accessTokenTtl |
300 (5 min) |
refresh_token_ttl |
homeserver.refreshTokenTtl |
86400 (24 h) |
Docker Compose sets the same values in config/matrix/tuwunel.toml.template.
The refresh lifetime is an idle timeout: each refresh moves the deadline
forward, so a drawer in use never expires, and a page left silent for a day
(e.g. on a suspended laptop) starts a new session through Waldur. 0 means the
refresh token never expires; the access token lifetime must be positive, as
Tuwunel reads 0 as "expire immediately". Waldur also signs out its chat
devices idle for more than 24 hours, when a user starts a session and in a
daily job at 03:45 UTC, so a refresh lifetime longer than a day, or 0, keeps
an idle drawer's device for at most about two days while the daily job runs:
one that has been idle just under 24 hours at the daily run is left until the
next. The job skips a run when the homeserver does not answer.
Clients that sign in without a refresh token, such as Element with a password, get non-expiring tokens and are unaffected.
Tuwunel reads its configuration only at startup. The homeserver pod carries a
checksum of the homeserver values, so a helm upgrade that changes any of
them restarts it. Secrets are not tracked, so that the annotation cannot be
used to test guesses at them: after changing registrationToken,
sso.clientSecret or a Secret you manage yourself, run
kubectl rollout restart statefulset/matrix-homeserver.
Capacity
Homeport keeps one /sync long-poll open on the homeserver for every
signed-in user who is a member of an active room, in every open tab, whether
or not the chat drawer is open: it connects in the background to show the
unread badge. Size the homeserver for those users, not only for the ones
chatting.
One homeserver supports 1000 concurrent users with 4 CPU cores. Measured on a MacBook Pro (Apple M4 Pro, 12 cores, 24 GiB) under Docker Desktop with 12 CPUs and 7.75 GiB, with Tuwunel v1.9.3 as Docker Compose configures it, which matches this chart on caches, presence and token lifetimes:
| Concurrent users | CPU limit | p95 delivery | CPU used, cores (average / peak) | Memory |
|---|---|---|---|---|
| 500 | 2 | 0.38 s | 0.6 / 2.1 | 250 MiB |
| 1000 | 2 | 71 s, fell behind | 2.0 / 2.2 | 470 MiB |
| 1000 | 4 | 0.41 s | 1.9 / 4.1 | 320 MiB |
| 500 | none | 0.10 s | 0.4 / 1.1 | 270 MiB |
| 1000 | none | 0.14 s | 1.8 / 4.3 | 310 MiB |
| 2000 | none | 1.6 s, p99 5.7 s | 5.4 / 9.3 | 490 MiB |
| 3000 | none | 77 s | 9.5, saturated | 1.1 GiB |
Delivery runs from the start of the send request to the message arriving in
another member's /sync. Users and rooms were created by Waldur: rooms of 25
members plus the bot, so Waldur's appservice received the homeserver's
transactions as in production. Each user synced as the drawer does (30 s
long-polls, presence on, access tokens refreshed every 5 minutes) and sent a
message every 5 minutes on average, busier than most project chats; one in ten
posted a read receipt for every message, as a drawer open on the room does.
Each step started with every user loading the page within 30 s.
CPU grows faster than the number of users: from 1000 to 2000 users it tripled. Idle connections are cheap (1000 users sending nothing took half a core); what costs is fanning messages and read receipts out to the members of each room. At 2000 users the page loads at the start saturated nine cores and p99 delivery passed 5 s; at 3000, messages took over a minute.
The figure does not cover:
- Server hardware. It was measured on a laptop, which also ran the rest of the Waldur stack and the load generator.
- End-to-end encryption. Messages were plain text of the size of an encrypted one. The drawer keeps its keys in memory, so every page load is a new device: it uploads its keys, the clients of the other members fetch them, and each sender's next message shares its session key with the new device, one to-device message per device in the room. A device signed out, by Waldur's daily prune too, makes senders share a new key with every device. Each page load also rehydrates the user's dehydrated device, which collects the room keys sent while no drawer was open and is then replaced by a fresh one, and restores the user's key backup in the background; both grow with the history the user can read. The dehydrated device is one more device per user that senders share keys with. None of this was simulated, so take the figure as an upper bound.
- Other traffic. One room per user, no typing notifications, media, calls or federation, and a database with little history. The ingress was not in the path. Calls add nothing to size for here: their media is encrypted end to end in the browsers and LiveKit only forwards the encrypted frames.
Before going past 1000 concurrent users, measure on your own hardware, on a test deployment rather than production, with load shaped like the above: users and rooms created through Waldur, so that its appservice is in the path, each user syncing as the drawer does, and every user loading the page at the start.
Homeserver resources
matrixChat.homeserver.resources defaults to {}, which does not fit
production. With no requests the pod is BestEffort: the scheduler reserves
nothing for it, and the kubelet evicts it first when its node runs short of
memory, which drops every chat connection at once. For up to 1000 concurrent
users:
1 2 3 4 5 6 7 8 9 10 | |
- CPU. At 1000 users the homeserver used under 2 cores on average, but page loads and bursts of messages took it to 4. Leave the limit off or set it to at least 4: under a limit of 2 the same load fell behind and never caught up.
- Memory. Tuwunel sizes its caches from the CPU cores of the node, not from a CPU limit: its defaults scale them with the core count, and on the 12-core machine above they could grow to about 2.4 GiB, under a 2-core limit as well. They fill as the history grows, not with the number of users, and held under 100 MiB here. A memory limit below them gets the pod OOM-killed once the database is large enough, so plan on roughly 1 GiB plus 0.2 GiB per core of the nodes it may run on, or leave the limit unset. The chart has no setting for Tuwunel's cache sizes.
Bot resources
The bot (matrixChat.bot) decrypts every message in every Waldur room, makes
every history export, up to three at a time, and writes rooms' earlier keys into
new members' key backups. The chart's defaults are requests of 100m CPU and
300Mi memory, limits of 500m and 600Mi. An export keeps a few MiB of
working files in memory and spills the rest, encrypted, to temporary files in
/tmp; with MATRIX_EXPORT_MEDIA on, each export holds up to 200 MB of media,
in files of up to 50 MB. One attachment at a time is held in memory, and saving
an export reads its files whole, so with media export on raise the memory limit
by a few hundred MiB if the bot is OOM-killed while exporting. Exports running
side by side take turns at both, so three exports need no more memory than one.
Database growth
With MATRIX_HISTORY_EXPORT_ENABLED on, a task at 02:00 every night queues a
full-history export of every active room, and with MATRIX_EXPORT_MEDIA on each
one carries its media too, up to 200 MB. Exports are stored, encrypted, in
Waldur's database and kept for MATRIX_HISTORY_EXPORT_RETENTION_DAYS, 90 by
default, along with each room's newest completed export whatever its age. Plan
PostgreSQL's volume and its backups for about 90 daily copies of every active
room's history and media; encrypted files do not compress.
One replica
Tuwunel is one process with an embedded RocksDB database on a ReadWriteOnce volume, so the chart runs it as a StatefulSet of exactly one replica. It scales up, not out: a second replica cannot share the load. Scaling up has diminishing returns, since each added core buys fewer users than the last: at the traffic above, 2000 concurrent users was already the knee with 12 cores.
Single sign-on for Matrix clients
With Waldur's MATRIX_EXTERNAL_LOGIN_METHOD set to oidc, users sign in to
Element or another Matrix client through the same identity provider (IdP) as
Waldur, into the account Waldur provisioned for them.
Single sign-on for Matrix clients
explains how the accounts line up and why the homeserver is configured this
way; this section covers the chart.
Waldur needs MATRIX_EXTERNAL_LOGIN_METHOD set to oidc, and
MATRIX_SSO_REGISTRATION_METHOD set to the name in Waldur of the identity
provider the homeserver signs in through (keycloak, say). Waldur gives a
Matrix account only to users who signed in to Waldur through that provider, so
while it is blank nobody gets one. With matrixChat.setup on (the default),
set sso.waldurRegistrationMethod and the wiring Job seeds both on every sync;
the chart refuses to render SSO without it. With setup off, set them yourself:
1 2 3 4 | |
Then configure the homeserver:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Register matrix-homeserver at the IdP with the redirect URI
https://<serverName>/_matrix/client/unstable/login/sso/callback/matrix-homeserver.
The client secret reaches the homeserver as a file from a Secret (the chart's
own matrix-homeserver-sso-secret when sso.clientSecret is set), never
through the ConfigMap. After changing a client secret, run
kubectl rollout restart statefulset/matrix-homeserver; changes to the other
chart values restart it on their own. The chart refuses to render SSO without
issuerUrl, clientId and a client secret, or unless apiScheme is https:
Tuwunel's SSO cookie is Secure.
The chart also refuses SSO together with allowRegistration: true. A trusted
provider signs a user in to any existing account named like their claim, so
anyone holding the registration token could register @bob first and receive
bob's SSO login. Waldur provisions accounts through its appservice, which works
with registration closed; create any homeserver admin before closing it.
The defaults make SSO sign in to the account Waldur provisioned, or refuse:
| Value | Default | Tuwunel key and chart checks |
|---|---|---|
sso.brand, sso.name |
keycloak, Single sign-on |
brand, the IdP software, and name, the label on the clients' sign-in button. |
sso.userIdClaims |
["sub"] |
userid_claims. A non-empty list of sub, preferred_username, username, nickname, email or login; the chart refuses anything else. Must be the claim Waldur's identity provider uses as user_claim, with its user_field left at username and MATRIX_USER_ID_FORMAT=username. |
sso.allowEmailClaim |
false |
Opt-in for email in userIdClaims, which the chart refuses otherwise: Tuwunel uses only the local part of the address, so alice@a.org and alice@b.org sign in to the same account. Set true only if the IdP issues addresses of a single domain. |
sso.waldurRegistrationMethod |
"" |
Not a Tuwunel key: Waldur's MATRIX_SSO_REGISTRATION_METHOD, the name in Waldur of this identity provider. With matrixChat.setup on, the wiring Job seeds it and MATRIX_EXTERNAL_LOGIN_METHOD: oidc, and the chart refuses SSO without it. |
sso.trusted |
true |
trusted. Signs in to any existing account named like the claim, so keep sub unless the IdP controls usernames. With false, Tuwunel refuses every account Waldur provisioned. |
sso.registration |
false |
registration. SSO creates no accounts; it only signs in to existing ones. With trusted, the chart refuses true unless sso.allowRegistrationWhenTrusted is also true: SSO would create an account before Waldur provisions it, and a trusted provider then signs anyone whose claim matches into it. |
sso.forbiddenUsernames |
[] |
forbidden_usernames: anchored patterns SSO never signs in to and Tuwunel refuses to register, so create an admin before you list it. The chart always puts the bot's localpart and waldur-bootstrap, reserved for the bootstrap admin that automatic registration creates, ahead of the list, so list only the other homeserver admins, such as admin above. The bot's localpart is setup.botLocalpart, or with setup off MATRIX_APPSERVICE_SENDER_LOCALPART from waldur.settingsOverrides where that is set; if you changed it in Waldur's settings instead, list it here too. |
loginWithPassword |
true |
login_with_password. Set false with SSO so clients show no password form. Waldur's drawer is unaffected, but an admin created with a password can then no longer sign in to a client either, so re-registering the appservice after a token rotation needs a homeserver admin's access token, or true again for the rotation. |
Password mode for Matrix clients
With MATRIX_EXTERNAL_LOGIN_METHOD set to password, users generate a Matrix
password in Waldur and sign in to Element with it. It is meant for testing and
sites without an identity provider; production uses single sign-on. Set the
method under Administration → Configuration → Matrix chat → Settings, or
with waldur.settingsOverrides: {MATRIX_EXTERNAL_LOGIN_METHOD: password}, and
keep homeserver.loginWithPassword: true.
Waldur sets the passwords through the homeserver's admin API, so the bot has to
be a homeserver admin; see
Making the bot a homeserver admin
for what that costs. The registration step makes it one, on every sync with
registerAppservice on, and Diagnostics shows the result as Bot is a
homeserver admin. If it could not, the wiring Job's log says why; add an
admin_token to the Secret for one sync, as in
Rotating with password login off, and it
tries again.
Runtime steps, and what now handles them
Four steps used to be manual, and all four failed silently when skipped. With
setup.enabled the chart handles all four.
- Backend token match — handled. Both sides are given the same generated
value; there is no
homeserver.registrationTokento keep in sync any more, and setting one whilesetup.enabledis true is a render error rather than a value that is quietly ignored. - Appservice registration — handled by
matrixChat.setup.registerAppservice(default on). The chart registers the appservice through Tuwunel's!admin appservices registeradmin-room command; Tuwunel can also read registrations from its config file, which the chart does not use. The wiring Job drives that command through the mastermind image'sregister_matrix_appservice, as a bootstrap admin it creates itself.
On a new homeserver the command creates @waldur-bootstrap through the
shared-secret registration API, with admin rights and bootstrap_password
as its password. The chart sets the homeserver's
registration_shared_secret to the registration token. The ingress routes
no /_synapse path, but any pod in the cluster can reach the API on the
homeserver's Service: matrixChat.networkPolicy is off by default, and when
on it admits that port from anywhere. Using the API still takes the
registration token, an admin's access token, or the as_token, which
reaches it as the bot once the bot is a homeserver admin. That first
run uses the token the registration returns, so it works with password
login off. Later runs sign in as @waldur-bootstrap with that password, and
every run signs the bootstrap session out when it is done. No account becomes an admin by being
the first one (grant_admin_to_first_user = false), so the users Waldur
registers stay ordinary users. forbidden_usernames does not block the
shared-secret API, so single sign-on can be on from the first install.
Leaving it on across upgrades is safe. Every sync signs in, compares the homeserver's registration with Waldur's and replaces it when the URL, a token or a namespace differs, losing a few seconds of events; otherwise it changes nothing. Without admin access it leaves a working registration alone with a warning, and fails only when Waldur turns the homeserver's ping away. Registering on Tuwunel from the command line has the details.
With registerAppservice: false nothing registers the appservice: chat
stays down until you register it by hand, and every token rotation needs the
same again, since the homeserver keeps the old registration. Run the command
in the API pod, handing it the bootstrap password on standard input so that
it stays out of the command line:
1 2 3 4 5 | |
To use a homeserver admin's access token instead, pipe that in and read it
into MATRIX_ADMIN_TOKEN. To paste the registration into the admin room
yourself, waldur generate_appservice_registration --url
http://waldur-mastermind-api.<namespace>.svc in the API pod prints it.
3. LoadBalancer IP — handled by livekit.rtc.autoDiscoverNodeIp (default
on). An initContainer reads the livekit-rtc Service's assigned external
address at pod start and writes it into LiveKit's config as node_ip.
Because it runs on every pod start rather than once at install, a LoadBalancer
that is reprovisioned with a different address self-heals on restart. Setting
rtc.nodeIp as well is a render error — pin it or discover it, not both.
The initContainer waits up to five minutes for the address, logging every
30 seconds, then exits non-zero; the pod sits in Init:Error /
Init:CrashLoopBackOff and the kubelet keeps retrying. There is no fallback
to LiveKit's own STUN discovery, which picks the wrong address behind a
cluster LoadBalancer: LiveKit would boot fine and every call would connect
with no media. A LoadBalancer that reports a hostname instead of an IP (AWS
ELB/NLB) is resolved to an address once per pod start, IPv4 first; restart
the pod if that address changes.
4. Homeserver URL — handled. The wiring Job seeds MATRIX_HOMESERVER_URL
with the homeserver's in-cluster Service
(http://matrix-homeserver.<namespace>.svc:<clientPort>), where Waldur
calls it for chat and to verify call tokens, and
MATRIX_HOMESERVER_PUBLIC_URL with <apiScheme>://<serverName>. Calls
therefore do not depend on pods reaching the ingress's public address.
These in-cluster calls are plain HTTP, as are the homeserver's calls to
Waldur (http://waldur-mastermind-api.<namespace>.svc) and Waldur's to the
bundled LiveKit. They carry the appservice tokens, the passwords and tokens
Waldur sets or mints for users, and LiveKit room-admin tokens, readable to
anything that can see traffic inside the cluster.
The Matrix bot
matrixChat.bot deploys mastermind's matrix_bot command, Waldur's member of
every Waldur room. It runs on a Matrix device of its own and holds that
device's keys. Every Waldur room is encrypted, so it is the only process that
can post into them: every message Waldur sends as the bot (role, order and
staff notices, command replies) waits in Waldur's database until the bot posts
it, and while the bot is not running, they wait. Clients share their room keys
with its device as with any member's, so it decrypts every message in those
rooms, not only the commands sent to it.
The bot also makes every history export and writes rooms' earlier keys into
new members' key backups, so that they can read what was said before they
joined. While it is down, exports stay pending and are made once it starts,
and disabling a room with MATRIX_HISTORY_EXPORT_ENABLED on cannot finish: the
room is not archived until its history is exported, so it goes to error at
once when the bot is not running, or after 15 minutes when the bot does not
finish the export in time. Disable it again once the bot runs.
- Exactly one replica. The bot holds a lease in Waldur's database, and a
second one refuses to start while the first holds it, so the Deployment uses
the
Recreatestrategy: the old pod releases the lease before the new one starts. - No volume. Its keys live in Waldur's database, in a
matrix_botschema, pickled under a key stored encrypted withFIELD_ENCRYPTION_KEY, or with a key derived fromSECRET_KEYwhile that is unset (the chart's default). Database backups carry them, and losing that key makes the store unreadable: the bot then refuses to start rather than reset its identity. Exports do spool encrypted temporary files to the container's/tmp, up to a few hundred MB each with media export on, which counts against the pod's ephemeral storage. - It needs the appservice registered (step 2 of
Runtime steps) and reaches the homeserver
at
MATRIX_HOMESERVER_URL, like the API. - Restoring a database elsewhere (a staging copy of production, say) copies
the bot's identity with it. Disable
matrixChat.botthere, or drop the copy'smatrix_botschema andmatrix_chat_matrixbotidentityrows, before the bot starts: otherwise a second bot runs as the same device, can read the original's rooms, and corrupts both bots' sessions. A copy that keeps the sameFIELD_ENCRYPTION_KEYalso yields every user's escrowed recovery key and every history export, so treat it as you would production.
Monitoring
The metrics exporter (waldur.metricsExporter) calls Waldur's Matrix
diagnostics, GET /api/admin/matrix/diagnostics/, about every two minutes
and publishes:
| Metric | Labels | Value |
|---|---|---|
waldur_matrix_diagnostics_up |
1 if the last diagnostics call succeeded, 0 if it failed |
|
waldur_matrix_check_passed |
check |
1 if the diagnostics check passed, 0 if not, e.g. homeserver_reachable, bot_running, bot_whoami, appservice_ping |
waldur_matrix_rooms |
state |
Rooms per state: creating, active, disabling, archived, error |
The exporter calls the endpoint with waldur.supportToken, which must belong
to a support or staff user. If every call gets 403, the token belongs to
neither, and waldur_matrix_diagnostics_up stays 0.
Scraping the exporter
A Prometheus that scrapes annotated Services finds the exporter through its
prometheus.io/scrape: "true" annotation
(waldur.metricsExporter.includeAnnotations, on by default). The Prometheus
Operator ignores those annotations; for it, turn on the ServiceMonitor below.
Alert rules
The chart ships the alert rules as a PrometheusRule, off by default:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Both objects need the Prometheus Operator's CRDs: the chart refuses to render
them on a cluster without the CRDs, and refuses the rules without the
exporter. helm template needs
--api-versions monitoring.coreos.com/v1/ServiceMonitor --api-versions monitoring.coreos.com/v1/PrometheusRule
to render them. kube-prometheus-stack picks up only ServiceMonitors and rules
labelled release: <its release name>; others are applied and then ignored,
without an error anywhere.
Without the Prometheus Operator, load the same rules into your Prometheus from
waldur/files/matrix-chat-alerts.yaml,
or copy them from the
Docker Compose guide.
The rules match the metrics of every Waldur the Prometheus scrapes, so with
several releases on one Prometheus, enable them in one. A Waldur that never
had Matrix chat raises no alert among them: its diagnostics report that no
homeserver URL is set, and the alerts stay quiet where that is so. One where
chat was switched off but MATRIX_HOMESERVER_URL is still set keeps raising
the homeserver and bot alerts. The other side of it: if that setting is
emptied on a Waldur that has chat, its four check alerts fall silent.
WaldurMatrixMetricsMissing fires only when no exporter at all publishes
Matrix metrics, so with several it does not notice one of them going away.
| Alert | Fires when | Severity | First thing to check |
|---|---|---|---|
WaldurMatrixMetricsMissing |
No waldur_matrix_diagnostics_up series for 15 minutes |
warning | The exporter pod runs a version with Matrix metrics, and Prometheus lists it as a target: a ServiceMonitor without the right labels is ignored. |
WaldurMatrixDiagnosticsDown |
The diagnostics call failed for 10 minutes | warning | The exporter's log. 403 means waldur.supportToken belongs to neither a support nor a staff user; anything else, the waldur-mastermind-api log. |
WaldurMatrixHomeserverUnreachable |
homeserver_reachable failed for 5 minutes while a homeserver URL is set |
critical | kubectl logs matrix-homeserver-0. A homeserver that is up but not answering after an image change is migrating its database: do not restart it, see Upgrading. |
WaldurMatrixBotNotRunning |
bot_running failed for 10 minutes while a homeserver URL is set |
warning | kubectl logs deployment/waldur-matrix-bot, and that matrixChat.bot.enabled is on. |
WaldurMatrixBotSignInFailing |
bot_whoami failed for 10 minutes while the homeserver is reachable |
warning | The appservice registration: the matrix-init-wire Job's log, and Rotating the tokens if the tokens changed. |
WaldurMatrixAppservicePingFailing |
appservice_ping failed for 10 minutes while bot_whoami passes |
warning | That the homeserver can reach waldur-mastermind-api at the registered appservice URL: the API pods and, if on, the NetworkPolicies. |
WaldurMatrixRoomsErred |
At least one room in the error state for 30 minutes |
warning | GET /api/matrix/rooms/?state=error lists them with their error_message. Fix the cause, then retry each room as staff with POST /api/matrix/rooms/<uuid>/retry/. |
When the homeserver is unreachable, the bot's whoami and the appservice ping
fail with it, so their alerts stay quiet while
WaldurMatrixHomeserverUnreachable fires. Waldur skips the ping when whoami
fails, so the ping alert also stays quiet while WaldurMatrixBotSignInFailing
fires.
The alerts read the :last20m series the same rules record, which keep each
check's and room count's last value for 20 minutes, so a brief exporter or
Prometheus restart neither resolves a real alert nor starts its timer over.
They are recorded per Waldur, not per scrape target. On Kubernetes, where a
target has a namespace label, a Waldur is a namespace and job, so an
exporter pod that is replaced continues the series. Elsewhere each target is
a Waldur. On a Prometheus that holds several clusters whose Waldurs share a
namespace name and job, the rules take those Waldurs for one. Leave
matrixChat.prometheusRule off there and load a copy of the rules file with
the cluster's label added to the by of the two :per_waldur rules.
The thresholds follow the exporter's two-minute refresh: 5 minutes is two or three failed refreshes in a row and 10 minutes about five, which rides out a pod restart.
Waldur never retries an erred room on its own, so WaldurMatrixRoomsErred
keeps firing until staff retry the rooms; the 30 minutes give staff already
handling an outage time to do that first.
Calls
Matrix clients find calls in the homeserver's .well-known/matrix/client. With
livekit.publicUrl set, the chart advertises one RTC focus there
(org.matrix.msc4143.rtc_foci):
1 | |
Element (Web, Desktop and mobile, through Element Call) and Waldur's chat
drawer both read it and ask Waldur's API for a LiveKit token:
POST /api/matrix/livekit/get_token, or the legacy /api/matrix/livekit/sfu/get
that Element Call falls back to. Waldur verifies the caller's Matrix OpenID
token with the homeserver, checks that the caller is joined to the room now and
that the device is theirs, and answers with the LiveKit URL and a token for
that room only. Anyone else gets 403.
The chart wires what this needs:
- Settings. The chart seeds
MATRIX_LIVEKIT_PUBLIC_URL(livekit.publicUrl),MATRIX_LIVEKIT_URL(the bundled LiveKit's in-cluster Service, or an external LiveKit's public URL ashttps://), andMATRIX_LIVEKIT_KEY/MATRIX_LIVEKIT_SECRETfrom the LiveKit Secret. Withsetup.enabled(the default) the wiring Job seeds them with the other Matrix settings, throughinit_matrix_settings, which fails the Job on a value it rejects; with setup off the init-whitelabeling job does. They are overwritten on everyhelm upgrade, like the other seeded settings; awaldur.settingsOverridesentry for either URL takes precedence, and one for the key or the secret is a render error while setup is on. The bundled LiveKit's room API (/twirp) is not routed through the ingress, so keepMATRIX_LIVEKIT_URLon the in-cluster Service; only the signalling websocket (/rtc) is public. Withwaldur.initdbEnabled: falsenothing is seeded: set them under Administration -> Settings instead. - Cross-origin access. Element calls the API from another origin. Waldur
answers these two paths with
Access-Control-Allow-Origin: *and no credentials. ingress-nginx'senable-corson the API ingress would replace that with the caller's origin plus credentials, so on nginx the chart routes/api/matrix/livekitthrough a separateapi-matrix-livekit-ingresswithout it. Traefik's API middleware already answers with*. - Homeserver access. Waldur checks OpenID tokens at
/_matrix/federation/v1/openid/userinfoonMATRIX_HOMESERVER_URL, the in-cluster client port. Tuwunel serves that endpoint with federation off too, so calls do not depend onhomeserver.allowFederation, public DNS or hairpin routing toserverName.
A call that shows Could not connect to the call. usually fails at the
token request; check it in the browser's network tab. 403 means Waldur
refused the caller (not joined to the room, or an unknown device). 503 means
Waldur could not reach the homeserver or LiveKit, or the LiveKit settings above
are missing.
Open-registration guard
With setup.enabled (the default) the registration token is always generated, so
this guard cannot trip. With setup.enabled: false, if
homeserver.allowRegistration is true but no registrationToken (or
registrationTokenExistingSecret) is set, the chart refuses to render — this
prevents shipping an open, abusable homeserver. Provide a token, or set
allowRegistration: false.
The chart fails the render in these further cases, to turn silent runtime breakage into an obvious config error:
matrixChat.enabledwithhomeserver.enabled: false— the homeserver is a bundled component, and the appservice is deliberately a root account over the whole@.*:<domain>namespace, so pointing Waldur at someone else's homeserver is unsafe by design and unsupported. This used to render cleanly and produce a deployment with no homeserver, no matrix ingress, and no chat host in the homeport CSP.setup.enabledwithhomeserver.registrationTokenorhomeserver.registrationTokenExistingSecretalso set (the token comes from the setup Secret, so a second value would drift), or withwaldur.initdbEnabled: false(Constance seeding needs a migrated database).-
livekit.rtc.autoDiscoverNodeIptogether with an explicitrtc.nodeIp, or with neither of the two. -
livekit.enabledwith no credentials (neitherlivekit.keys.apiKey+apiSecretnorlivekit.keys.existingSecret.name) — otherwise livekit-server starts with no signing key and Waldur cannot sign call tokens. livekit.keys.apiSecretshorter than 32 characters — livekit-server only warns and starts anyway, shipping a weak signing key.livekit.turn.enabledwith noturn.domainor noturn.tls.existingSecret— a TURN relay with no reachable hostname or no cert boots but never accepts a connection, so the clients that need it (symmetric NAT) fail silently.
Why the RTC LoadBalancer
WebRTC media is UDP/TCP and cannot traverse an L7 ingress, so livekit-rtc is a
LoadBalancer — the only one in the chart. Signaling (wss) and everything else
ride the shared matrix ingress on serverName.
TURN relay (clients behind symmetric NAT)
By default LiveKit only offers direct host candidates (rtc.udpPort /
rtc.tcpPort on rtc.nodeIp). A client on a cone NAT connects fine, but a client
behind a symmetric NAT — corporate CGNAT, or iCloud Private Relay — reaches
signaling and then has every ICE pair fail: the call joins but carries no media.
The only fix is a TURN relay both peers connect out to.
Enable LiveKit's built-in TURNS (TURN over TLS) — no separate coturn pod:
1 2 3 4 5 6 7 | |
Two things the operator must wire (the chart can't):
- DNS.
turn.domainmust resolve to thelivekit-rtcLoadBalancer IP (the same target asrtc.nodeIp) — notserverName, which points at the matrix ingress where nothing listens ontlsPort. Use a dedicated name, e.g.turn.<serverName>. TURNS rides the rtc LoadBalancer because TURN is its own protocol and can't go through the L7 ingress. - Cert. LiveKit terminates the TURNS TLS itself, so it needs a cert + key for
turn.domaininturn.tls.existingSecret(e.g. a cert-managerCertificate).
TURNS on tlsPort also tunnels through TLS-only firewalls, so it covers both
symmetric NAT and restrictive networks. That holds only on 443, the default:
those firewalls allow nothing else, so keep it. Plain TURN/UDP is
intentionally not exposed — relayed media always rides TLS.
What the chart wires automatically
- Homeport CSP. Enabling matrix injects the homeserver host into the homeport
Content-Security-Policy:
connect-srcgetshttps://<serverName>(chat sync) andwss://<serverName>(call signaling);media-src/img-srcgethttps://<serverName>(chat media/images). Without this the browser would block the chat client and calls. No action needed — it followshomeserver.serverNameand is a no-op when matrix is disabled. - WebAssembly in the homeport CSP.
script-srcalways includes'wasm-unsafe-eval'. Chat is end-to-end encrypted, and the browser runs the encryption as WebAssembly, which it refuses to compile without this source. It allows compiling WebAssembly only, noteval()of JavaScript. Without it the chat drawer reports that encryption is unavailable in this browser. - The call-encryption worker. Calls encrypt their media in a Web Worker
that homeport serves from its own origin (
/assets/e2eeWorker-*.js), soscript-src 'self'already covers it and the chart sets noworker-src. A policy of your own that setsworker-srcmust include'self', or calls in encrypted rooms fail. - NetworkPolicies. When
matrixChat.networkPolicy.enabledistrue, the chart adds a policy per pod. This gate is independent of the chart-widenetworkPolicy.enabled(which only covers the homeport/mastermind-api policies) — set it to ship the matrix/livekit policies without opting the rest of the stack into NetworkPolicy. The livekit policy accepts media from anywhere (external WebRTC). The homeserver policy accepts HTTP from anywhere on its service port too, because browser requests reach it through the ingress controller, which runs in its own namespace. Egress is left open on both, for federation.
Using an external LiveKit
You can run the Matrix calling stack against an operator-managed LiveKit SFU
instead of the bundled livekit-server — the same bring-your-own-backend pattern
the chart offers for PostgreSQL. Tuwunel stays bundled; only the SFU is external.
Why it works: the browser reaches LiveKit client-side via the URL Waldur returns
with each call token (livekit.publicUrl), and Waldur signs those tokens with an
API key/secret it shares with the LiveKit server. Waldur also calls the external
LiveKit's room API at the same address over https://. Point both at your
external instance and the bundled SFU is never needed.
Configuration:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
The existingSecret must hold the same API key and secret configured on your
external LiveKit, under the keys named above. If you omit existingSecret.name
while livekit.enabled=false, the chart refuses to render — the bundled
livekit-secret only exists when the bundled server is deployed, so Waldur
would have no key to sign call tokens with.
With livekit.enabled=false the chart drops the in-cluster LiveKit Service, its
config.yaml, network policy, RTC LoadBalancer, and the /rtc ingress
route. The browser connects straight to publicUrl, so reachability, TLS, and the
media plane are the external operator's responsibility. The chart adds the
publicUrl origin to the homeport Content-Security-Policy connect-src on every
ingress variant, so the browser is allowed to open the signalling websocket to it;
nothing needs configuring for that.