Matrix chat add-on
Waldur ships an optional Matrix chat integration (the matrix_chat Django app and the homeport src/matrix/ views). When the add-on is enabled, project members can chat in per-project Matrix rooms; with the matrix-rtc sub-profile they can also start voice/video calls from the chat.
The add-on bundles a Tuwunel homeserver (the same image used by the upstream dev stack) wired into the Caddy reverse proxy on the same domain — no extra DNS, no federation port. Tokens are auto-generated on first start and persisted in a Docker volume.
Activation
Chat only:
1 | |
Chat + voice/video calls, with WALDUR_MATRIX_RTC_ENABLED=true in .env:
1 | |
The matrix-rtc profile adds only the LiveKit media server; calls also need the matrix profile. See Calls for how clients get call tokens.
Put the profiles in .env as well: COMPOSE_PROFILES=matrix, or COMPOSE_PROFILES=matrix,matrix-rtc with calls. Every docker compose command then includes the add-on, the plain commands in the README's upgrade included. Without it, a command without --profile leaves the Matrix services out: pull and up -d neither update nor start them, so after an upgrade the bot is not updated and waldur-matrix-init and waldur-matrix-register do not run. A --profile flag replaces COMPOSE_PROFILES for that one command, so the commands below that pass only --profile matrix leave LiveKit as it is.
The add-on runs waldur init_matrix_settings and waldur register_matrix_appservice from the mastermind image, so it needs WALDUR_MASTERMIND_IMAGE_TAG at the version .env.example pins or newer. With an older image waldur-matrix-init fails on the unknown command and Tuwunel never starts.
Pinned image tags
Matrix component versions live in .env. Bump them deliberately:
| Variable | Default | Component |
|---|---|---|
WALDUR_TUWUNEL_IMAGE_TAG |
v1.9.3 |
Tuwunel homeserver — requires 1.7.0+ for Synapse-compatible registration_shared_secret. Kept in lockstep with the Helm chart's matrixChat.homeserver.imageTag — one supported homeserver version across both packaging paths |
WALDUR_LIVEKIT_IMAGE_TAG |
v1.13.7 |
LiveKit SFU |
Both publish multi-arch (linux/amd64 and linux/arm64) manifests for the pinned tags. Re-check with docker buildx imagetools inspect <image>:<tag> after bumps.
A tag can be moved to another image; a digest cannot. Either variable also takes <tag>@sha256:<digest> to pin the image itself. Two other images the add-on depends on are moving tags: caddy:2, which serves /_matrix, and postgres:${WALDUR_POSTGRES_IMAGE_TAG} (16), which holds Waldur's database, the bot's keys included.
Back up before bumping Tuwunel. It migrates its embedded database in place
on the first boot of a new version, before it listens (from 1.9.1 it logs its
progress every fifteen seconds; earlier versions log nothing), so read the
upstream release notes
before moving in either direction. A tuwunel container that is up but not
answering /_matrix/client/versions is migrating, not hung: do not restart it,
a restart mid-migration corrupts the database.
Downgrades are the dangerous direction. An older Tuwunel boots cleanly on a
migrated database and then silently serves stale data from the old stores. Stop
the stack and back up the tuwunel_data volume before changing the tag;
restoring that backup is the only rollback (see
Backup and restore). The Helm chart's
Matrix chat guide
has the upgrade and CVE-response procedure, which applies to both packaging
paths.
WALDUR_DOMAIN is frozen once the homeserver has data. It is the Matrix
server_name, baked into every user and room ID. The homeserver also stamps
it into the database on first boot and refuses to start under a different
name:
1 | |
That is not a bug to work around by wiping tuwunel_data — wiping it discards
the whole chat corpus. Changing the domain means a fresh homeserver.
Never run docker compose down -v with the matrix profile active. It
deletes the tuwunel_data and waldur_matrix_secrets volumes, with every
message and the tokens, but not Waldur's database, which lives in the
./pgsql bind mount. Waldur is then left with rooms, accounts and a bot
device that the homeserver no longer has, and the next up generates new
tokens.
Appservice registration
This stack registers the appservice through Tuwunel's !admin appservices register admin-room command; Tuwunel can also read registrations from its config file, which the stack does not use. This is handled for you: the one-shot waldur-matrix-register container runs after the homeserver comes up, drives that admin-room command and makes the bot a homeserver admin (see below). There is nothing to paste.
On a new homeserver it first creates a bootstrap admin, @waldur-bootstrap, through Tuwunel's shared-secret registration API on the internal network. The generated registration token is the shared secret, and BOOTSTRAP_PASSWORD from the secrets volume becomes the account's password. 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.
1 2 3 4 | |
up -d returns before registration is done: nothing depends on the register
container, and it may still be waiting for the homeserver. Read its log once
docker wait has returned.
The register container waits for the homeserver before it does anything, up to
WALDUR_MATRIX_REGISTER_WAIT_SECONDS (default one hour), logging every 30 s.
After a Tuwunel bump that wait covers the in-place database migration, which
runs silently before the homeserver listens and scales with the volume size.
If the log shows the container gave up, raise the limit and up again — do not
restart tuwunel.
Re-running up -d is safe. With a working appservice token, the command signs in, compares the homeserver's registration with Waldur's and replaces it when the URL, a token or a namespace differs; otherwise it changes nothing. Registering on Tuwunel from the command line has the details.
Registering by hand
Set WALDUR_MATRIX_REGISTER_APPSERVICE=false to keep registration entirely
manual. waldur-matrix-init still renders the descriptor into the
waldur_matrix_secrets volume with mastermind's
generate_appservice_registration, so it claims the same user and room-alias
namespaces the register container would.
The default config has WALDUR_MATRIX_OPEN_REGISTRATION=false, so signing up from a client, such as Element Web's sign-up form, needs the registration token: no client can sign up without it. The appservice token can still register accounts, as Tuwunel exempts appservice requests from the token check. Use Tuwunel's Synapse-compatible admin endpoint (HMAC-keyed by the registration secret) to provision the admin user. That endpoint is not served through Caddy, so the snippet below runs inside the Compose network, in a throwaway waldur-matrix-init container that has the secrets volume mounted. It does the whole thing — create the admin, find the admin room, post the !admin appservices register message with the descriptor and then !admin users make-user-admin for the bot, print Tuwunel's replies. Choose the admin's username, and type its password at the prompt, so it lands neither in shell history nor on a command line:
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | |
The bot then becomes @waldur-bot:<your-domain> (WALDUR_MATRIX_BOT_LOCALPART) and can post on Waldur's behalf.
The register container, or the snippet's second command, makes the bot a homeserver admin. Waldur calls the homeserver's admin API as the bot: resetting a user's chat encryption from the drawer goes through /_synapse/admin/v1/reset_password, and a user deactivated in Waldur is locked on the homeserver the same way. Without admin rights the homeserver refuses those calls. Nothing makes the bot an admin by accident: Tuwunel runs with grant_admin_to_first_user = false, and the admin API is reachable only on the internal network.
Treat the appservice token as a homeserver admin credential. Waldur's registration claims every local user ID, so the appservice token (AS_TOKEN) can act as any account on the homeserver, @waldur-bootstrap and the other admins included, from the moment the appservice is registered. Once the bot is an admin, the token can also call the admin API as the bot, for example to reset any account's password and take it over. On Tuwunel, being a homeserver admin means being a member of #admins:<your-domain>, so make-user-admin joins the bot to that room. The bot itself answers commands only in Waldur's own rooms: admin-room traffic reaches its sync but triggers nothing. Anyone holding the appservice token, however, can post !admin commands there as the bot.
The token is stored in four places: secrets.env and the rendered waldur-registration.yaml in the waldur_matrix_secrets volume; Waldur's Constance settings in waldur-db, which Waldur staff can read through the settings API; and the homeserver's tuwunel_data, which holds the registration and the admin room's history, where each registration is posted as a plain message, HS_TOKEN included. Backups of both volumes and of the database carry it. Tuwunel 1.9.3 also writes the HS_TOKEN into its log, docker logs tuwunel, whenever a call to Waldur fails to go through, because the token travels in the URL of each call and the warning quotes it. Restrict access to the volumes, the database, the homeserver's log and their backups accordingly, and if a token may have been exposed, rotate it as described under Token rotation.
Admins are created through the shared-secret API, with "admin": true, as the register container and the snippet do. Tuwunel runs with grant_admin_to_first_user = false, so no account becomes an admin just by being the first one. Otherwise Waldur, which registers an account for whoever opens the chat first, could hand a user the homeserver's admin room. Once created, the admin can sign in to Element Web with that username and password.
/_synapse/admin/* is not served through Caddy, which answers it with 404; Waldur reaches the admin API on the internal network (MATRIX_HOMESERVER_URL).
On that network Waldur and the homeserver call each other over plain HTTP (http://tuwunel.internal:6167, http://waldur-mastermind-api:8080), and Waldur calls LiveKit's room API the same way (http://livekit.internal:7880). These calls carry the appservice tokens, the passwords and tokens Waldur sets or mints for users, and LiveKit room-admin tokens. Every container on the Compose network can reach those addresses.
The Matrix bot
The matrix profile also starts waldur-matrix-bot, 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 waits in waldur-db until the
bot posts it. 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. It needs the appservice registered (above): until then it
restarts with a sign-in error. It also needs to be a homeserver admin (above)
for the drawer's encryption reset and for locking users deactivated in Waldur.
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.
Run exactly one. It holds a lease in waldur-db, and a second one refuses to
start while the lease is held. Its keys live in waldur-db too (schema
matrix_bot), pickled under a key that is encrypted with Waldur's field
encryption key, FIELD_ENCRYPTION_KEY, or with a key derived from
GLOBAL_SECRET_KEY while that is unset (the default), so a database backup
carries them and no volume is needed.
Exports do spool encrypted temporary files to the container's /tmp, up to a
few hundred MB each with media export on.
1 | |
Because its keys live in the database, a copy of that database carries the
bot's identity. Before starting a stack on a restored copy of another
deployment's database (a staging copy of production, say), leave out the
matrix profile or drop the copy's matrix_bot schema and
matrix_chat_matrixbotidentity rows. 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 same FIELD_ENCRYPTION_KEY also yields every user's
escrowed recovery key and every history export, so treat it as you would
production.
Enabling the homeport UI
Backend access to Matrix is gated by the MATRIX_ENABLED Constance flag. waldur-matrix-init turns it on the first time; switched off in Waldur, it stays off across later up runs. The homeport UI is gated separately by a feature flag — enable it once via the load_features management command:
1 2 | |
After a hard reload (Cmd-Shift-R / Ctrl-Shift-R), project views show the Communication tab — but only after a Matrix room has been created for that project (the tab is gated by hasActiveProjectMatrixRoomInCache). Until then, the room-creation entry point lives at Manage → Chat (?tab=chat query parameter — direct paths like /manage/chat/ return 404).
Token rotation
To rotate the AS/HS tokens (e.g., after credential exposure), replace both in the secrets volume and bring the profile up again. Always both, since either may have leaked.
1 2 3 4 5 6 7 8 | |
waldur-matrix-init seeds the new tokens into Constance, and
waldur-matrix-register signs in as @waldur-bootstrap, 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. The container fails if the homeserver still rejects the new token.
Chat is down from the moment init seeds the new tokens until the register
container finishes, usually tens of seconds. Events sent in that window, such as
a bot command, are not delivered to Waldur. The registration token and the room
database in tuwunel_data are untouched, so existing users and rooms survive.
Do not delete the secrets volume to rotate: that also replaces BOOTSTRAP_PASSWORD,
which then no longer matches @waldur-bootstrap on the homeserver, and
registration fails.
With WALDUR_MATRIX_REGISTER_APPSERVICE=false the register container does
nothing, --admin-token-stdin included, so neither recipe on this page
replaces the registration. After the up, which renders the new tokens into
waldur-registration.yaml, post !admin appservices unregister waldur in the
admin room as a homeserver admin, then !admin appservices register with the
new descriptor, as in Registering by hand. Running that
snippet again does not do it: its first step, creating the admin, fails because
the account exists. To print the descriptor:
1 2 | |
Rotating with password login off
With WALDUR_MATRIX_LOGIN_WITH_PASSWORD=false, as with single sign-on, the
bootstrap admin cannot sign in. An up with working tokens then cannot read
the homeserver's copy of the registration, so it only warns and changes
nothing, and a rotation fails after init has seeded the new tokens, with
"Password login is disabled on the homeserver". Run the register container once
more with a homeserver admin's access token on its standard input. Passed this
way, the token lands in no container's environment, where docker inspect
would show it, and the one-off container is removed when it exits.
To get that token, create a temporary admin and, after the run, have it
deactivate itself, with the two waldur shell snippets under
Token rotation
in Waldur's admin guide. Run them in the worker container: it reaches the
homeserver's admin API, which Caddy does not serve, and holds the registration
secret in Constance, the same value as REG_TOKEN in secrets.env, so no
secret leaves Waldur. Put docker exec waldur-mastermind-worker in front of the
first snippet's waldur shell, and docker exec -i in front of the second's,
which reads the token on its standard input. Between the two, register with
the token:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
The same run with an admin's token applies any other change the bootstrap
admin cannot read with password login off, such as a new
WALDUR_MATRIX_APPSERVICE_URL.
Rotating the registration token
REG_TOKEN is both the homeserver's registration token and the shared secret
that creates homeserver admins, and Waldur staff can read it in Constance
(MATRIX_USER_REGISTRATION_SECRET). Rotate it on its own, leaving the other
lines of secrets.env as they are:
1 2 3 4 5 | |
up seeds the new token into Constance and renders it into tuwunel.toml,
but leaves Tuwunel running with the old one, since it reads its configuration
only at start; the restart applies it. Until then Waldur provisions new users
through its appservice instead of with the token. The register container
signs in with BOOTSTRAP_PASSWORD and does not use the token on a homeserver
that is already set up, so it is not affected.
To replace BOOTSTRAP_PASSWORD, change it on the homeserver first: as a
homeserver admin, post !admin users reset-password waldur-bootstrap in the
admin room, and Tuwunel answers with a generated password. With password login
on, that admin can be @waldur-bootstrap itself in a Matrix client; with it
off, only a temporary admin's token can post it, as the setup guide's second
snippet posts its command. Then write that
into the BOOTSTRAP_PASSWORD= line of secrets.env the same way. The reply
stays in the admin room's history in tuwunel_data, as the registrations do.
Token lifetimes
Waldur's chat drawer signs in with a refresh token, so its access tokens expire after access_token_ttl (300 seconds) and are renewed in the background; a page left silent for refresh_token_ttl (86400 seconds, idle), e.g. on a suspended laptop, starts a new session through Waldur. 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 whatever the refresh lifetime, an idle drawer's device lasts at most about two days while the daily job runs; it skips a run when the homeserver does not answer. Clients that sign in without a refresh token, such as Element with a password, keep non-expiring tokens. To change the lifetimes, edit config/matrix/tuwunel.toml.template; access_token_ttl must be positive, as Tuwunel reads 0 as "expire immediately".
Tuwunel reads its configuration only at startup, and tuwunel.toml is rendered from the template only when waldur-matrix-init runs, on up. After upgrading or editing the template, re-render it and restart Tuwunel, which an up leaves running with the old values:
1 2 | |
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 this add-on (Tuwunel v1.9.3, the shipped
tuwunel.toml.template):
| Concurrent users | cpus on tuwunel |
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. Caddy 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
The compose file sets no limits on tuwunel, so it shares the host with
Waldur. For up to 1000 concurrent users, give the host about 4 cores and 4 GiB
of memory on top of what Waldur needs. To cap the homeserver, add an override:
1 2 3 4 5 6 | |
- CPU. At 1000 users the homeserver used under 2 cores on average, but page
loads and bursts of messages took it to 4. Leave
cpusoff or set it to at least 4: at 2 the same load fell behind and never caught up. - Memory. Tuwunel sizes its caches from the CPU cores of the host, not from
cpus: its defaults scale them with the core count, and on the 12-core machine above they could grow to about 2.4 GiB, withcpus: 2as well. They fill as the history grows, not with the number of users, and held under 100 MiB here. Amem_limitbelow them gets the container killed once the database is large enough, so plan on roughly 1 GiB plus 0.2 GiB per core of the host.
Bot resources
The bot (waldur-matrix-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 compose file sets no limits on it. An export keeps
a few MiB of working files in memory and spills the rest, encrypted, to
temporary files in the container's /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 leave the bot a few hundred MiB of headroom, and more if you
cap it with mem_limit. 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-db and kept for MATRIX_HISTORY_EXPORT_RETENTION_DAYS, 90 by default,
along with each room's newest completed export whatever its age. Plan the disk
behind waldur-db 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 the tuwunel_data
volume. It scales up, not out: a second instance 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 compose settings.
Register a client at the IdP with the redirect URI
https://<WALDUR_DOMAIN>/_matrix/client/unstable/login/sso/callback/<client id>,
then set in .env:
1 2 3 4 5 6 7 8 9 10 | |
and run docker compose --profile matrix up -d, which re-renders the homeserver
configuration and recreates tuwunel. After changing only
WALDUR_MATRIX_SSO_CLIENT_SECRET, also run docker compose restart tuwunel.
With single sign-on on, waldur-matrix-init also seeds Waldur's
MATRIX_EXTERNAL_LOGIN_METHOD as oidc and MATRIX_SSO_REGISTRATION_METHOD
from WALDUR_MATRIX_SSO_REGISTRATION_METHOD: the name of the Waldur identity
provider this IdP is, such as keycloak. Waldur gives a Matrix account only to
users who signed up through that provider. Both are seeded on every up, so
while single sign-on is on, a method changed in Administration goes back to
oidc. With single sign-on off it seeds neither, so a method set under
Administration → Configuration → Matrix chat → Settings stays as it is.
That includes oidc itself: when you turn single sign-on off, set the method
back to none or password there. Otherwise Waldur keeps refusing chat to
users who did not sign up through the identity provider, and keeps pointing
users at a single sign-on the homeserver no longer offers.
waldur-matrix-init writes the client secret to sso_client_secret in the
secrets volume, not into tuwunel.toml. It checks the settings before it
renders anything, and a refused run keeps the previous configuration. It
refuses a missing issuer, client ID, secret or registration method, a WALDUR_MATRIX_SSO_ENABLED
other than true or false, a name with quotes, backslashes or control
characters, a client ID or brand with anything but letters, digits, ., _
and -, an issuer that is not an https:// URL, and an unrecognised claim.
It also refuses single sign-on together with
WALDUR_MATRIX_OPEN_REGISTRATION=true: with trusted, anyone could register
@bob first and receive bob's SSO login. Registration with the token stays
open, though: tuwunel.toml keeps allow_registration = true with the
registration token from secrets.env, which Waldur also holds in its settings,
where staff can read it. Whoever holds the token can register @bob before
Waldur provisions bob, and then receives bob's SSO login too. The Helm chart
refuses single sign-on with any registration open; Waldur provisions users
through its appservice when registration is closed. The homeserver is
configured so SSO lands in Waldur's account:
nameisWALDUR_MATRIX_SSO_NAME, the label of the login button.brandisWALDUR_MATRIX_SSO_BRAND, the kind of IdP (keycloak,github,gitlab,google,mas), from which Tuwunel takes provider-specific defaults and workarounds.issuer_urlmust be what the IdP publishes asissuerin its discovery document. Tuwunel fetches that document from inside its container at the first SSO login, not at startup, so the issuer must resolve there and present a certificate trusted there. The bundled Keycloak (--profile keycloak,https://<WALDUR_DOMAIN>/auth/realms/<realm>) works only with aWALDUR_DOMAINthat resolves to the host from inside containers and a certificate from a public CA, notTLS=internal.userid_claimsisWALDUR_MATRIX_SSO_USERID_CLAIM, one ofsub,preferred_username,username,nickname,email(its local part) orlogin(GitHub).emailis refused unlessWALDUR_MATRIX_SSO_ALLOW_EMAIL_CLAIM=true: only the local part is used, soalice@a.organdalice@b.orgwould sign in to the same account; set it only if the IdP issues addresses of a single domain. It must be the claim Waldur's identity provider uses asuser_claim, with that provider'suser_fieldleft atusernameandMATRIX_USER_ID_FORMAT=username, and its values must already be valid Matrix localparts (lowercase letters, digits,. _ - / +).trusted = truesigns in to the existing account that matches the claim, and that is any existing account with that name, so keepsubunless the IdP controls usernames. Without it, Tuwunel refuses the login: it signs in only to accounts it created through SSO, and Waldur provisioned these.registration = false: SSO creates no accounts and only signs in to existing ones, withtrustedany whose name matches the claim, henceforbidden_usernames.forbidden_usernamescloses accounts that are not a Waldur user's to SSO. The bot's localpart (WALDUR_MATRIX_BOT_LOCALPART) andwaldur-bootstrap, reserved for the bootstrap admin that automatic registration creates, are on it automatically. Create any other homeserver admin under a localpart no IdP user can hold, such asmatrix-admin, and list it inWALDUR_MATRIX_SSO_FORBIDDEN_USERNAMES(comma-separated). Admins on the list can still be created through the shared-secret registration API; SSO cannot sign in to them. Tuwunel logs a warning at startup for each existing account on the list; that is expected.
WALDUR_MATRIX_LOGIN_WITH_PASSWORD=false removes the password form from
clients; Waldur's chat drawer signs in through the appservice and is
unaffected. It applies to every account, so the admin created with a password
in the appservice registration cannot sign in to a
client either. Rotation does not need it: see
Rotating with password login off.
Calls
With WALDUR_MATRIX_RTC_ENABLED=true, the homeserver's .well-known/matrix/client advertises one call focus (org.matrix.msc4143.rtc_foci), pointing at Waldur's own API:
1 | |
Element (Web, Desktop and mobile, through Element Call) and Waldur's chat drawer both read it and ask Waldur 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 at http://tuwunel.internal:6167, checks that the caller is joined to the room now and that the device is theirs, and answers with wss://${WALDUR_DOMAIN}/livekit and a token for that room only. Anyone else gets 403.
waldur-matrix-init seeds the settings Waldur needs on every up: MATRIX_LIVEKIT_PUBLIC_URL (wss://${WALDUR_DOMAIN}/livekit), MATRIX_LIVEKIT_URL (http://livekit.internal:7880, the room API on the Compose network) and MATRIX_LIVEKIT_KEY / MATRIX_LIVEKIT_SECRET (from WALDUR_LIVEKIT_KEY / WALDUR_LIVEKIT_SECRET). Caddy routes only LiveKit's signaling under /livekit/: its room API (/livekit/twirp/) answers 404 from outside, since Waldur reaches it on the Compose network.
Element calls these endpoints from another origin. Waldur answers them with Access-Control-Allow-Origin: * and no credentials, and the Caddyfile keeps its site-wide CORS headers (the request's origin plus credentials) off /api/matrix/livekit/, so the two never collide.
The OpenID check goes to the homeserver on the internal network, so calls work with WALDUR_DOMAIN=localhost and do not need Tuwunel's federation.
LiveKit / voice & video notes
WALDUR_LIVEKIT_NODE_IP advertises the host's RTC media address to clients. The default 127.0.0.1 is correct for a local demo only — for any reachable deployment, set this to the host's public IP address so remote clients can connect. It must be an address: LiveKit refuses to start with a host name (invalid ip). If you only have a name, resolve it and put the result in .env, e.g. dig +short A waldur.example.org | tail -n 1.
The RTC media ports, 7881/tcp and 7882/udp, must also be reachable from clients. LiveKit advertises these container ports at WALDUR_LIVEKIT_NODE_IP and knows nothing of Docker's port mapping, so keep WALDUR_MATRIX_RTC_TCP_PORT and WALDUR_MATRIX_RTC_UDP_PORT at their defaults: with another host port, clients still dial 7881/7882 and calls carry no media, unless something in front of the host forwards those ports to the ones you chose.
WALDUR_LIVEKIT_KEY / WALDUR_LIVEKIT_SECRET default to development values. Anyone who knows them can mint a token for any call, so override both for anything beyond a local demo, with a secret of at least 32 characters. With --profile matrix-rtc and a WALDUR_DOMAIN other than localhost or host.docker.internal, the one-shot livekit-credentials-check refuses the development values or a shorter secret, and docker compose up -d fails with service "livekit-credentials-check" didn't complete successfully. livekit, which waits for the check, does not start. The failed command can also leave other services unstarted: on a first start, or after docker compose down, the API, the workers, HomePort, Caddy and the homeserver stay down, so Waldur itself is down, not only calls, while the database migration may still have run. Services that were running and that this up does not change keep running. Set WALDUR_LIVEKIT_KEY and WALDUR_LIVEKIT_SECRET in .env, or turn calls off again (WALDUR_MATRIX_RTC_ENABLED=false and no --profile matrix-rtc), and run up -d again.
The LiveKit secret, and the single sign-on client secret, reach containers as environment variables: both waldur-matrix-init's, the LiveKit secret also livekit-credentials-check's and livekit's LIVEKIT_CONFIG. docker inspect on those containers shows them.
TURN relay (symmetric NAT / iCloud Private Relay)
Direct media to WALDUR_LIVEKIT_NODE_IP fails for clients behind symmetric NAT or proxies that don't carry WebRTC UDP. Enabling TURN gives LiveKit a relay those clients fall back to.
Set WALDUR_MATRIX_TURN_ENABLED=true (within --profile matrix-rtc). The stack uses TURN/UDP on port 443 — udp_port is the port LiveKit advertises to clients, and 443 is the firewall-friendly choice. Because the stack is single-host, the relay lives at WALDUR_DOMAIN:443/udp — the same IP that already serves the web UI, so no extra DNS record is needed.
This mode needs no certificate: TURN/UDP isn't TLS, so there is nothing to provision or renew. It also doesn't collide with Caddy — Caddy binds 443/tcp (HTTPS) while TURN takes 443/udp, which is otherwise free on the host.
Open 443/udp on any upstream firewall / cloud security group (the same way 443/tcp is already opened for the web UI). Keep WALDUR_MATRIX_TURN_UDP_PORT at 443 for the same reason as the media ports: LiveKit advertises the relay on 443 whatever the host port is.
1 2 | |
Coverage note: TURN/UDP rescues symmetric-NAT clients and any network that permits outbound UDP on 443. It does not help clients on networks that block all outbound UDP (strict corporate firewalls) — that requires TURN/TLS on TCP 443, which on a single host means giving LiveKit its own IP or a layer-4 load balancer (the helm topology). For a single-host compose deployment, TURN/UDP is the pragmatic option.
Monitoring
The stack runs neither the metrics exporter nor Prometheus. To watch chat health from outside Waldur, run waldur-prometheus-exporter next to the stack and load the rules below into your Prometheus.
The exporter 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 needs the API token of a support or staff user. If every call
gets 403, the token belongs to neither, and waldur_matrix_diagnostics_up
stays 0.
Running the exporter
Put the exporter in a docker-compose.override.yml next to
docker-compose.yml, which docker compose reads unless -f names the files,
and set WALDUR_METRICS_EXPORTER_TOKEN in .env to the API token of a
support or staff user:
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Then have Prometheus scrape it and load the alert rules below,
saved as matrix-chat-alerts.yml next to prometheus.yml:
1 2 3 4 5 6 7 | |
Alert rules
The same rules ship with the Helm chart as a PrometheusRule:
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | |
The rules match the metrics of every Waldur the Prometheus scrapes. A stack
that never ran the matrix profile raises no alert among them: the endpoint
answers there too, its diagnostics report that no homeserver URL is set, and
the alerts stay quiet where that is so. One that ran the profile and no longer
does still has MATRIX_HOMESERVER_URL set, and keeps raising the homeserver
and bot alerts. The other side of it: if that setting is emptied on a stack
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 | docker logs waldur-metrics-exporter, that the image is a version with Matrix metrics, and that Prometheus lists the exporter as a target. |
WaldurMatrixDiagnosticsDown |
The diagnostics call failed for 10 minutes | warning | docker logs waldur-metrics-exporter. 403 means the token belongs to neither a support nor a staff user; anything else, docker logs waldur-mastermind-api. |
WaldurMatrixHomeserverUnreachable |
homeserver_reachable failed for 5 minutes while a homeserver URL is set |
critical | docker logs tuwunel. A tuwunel container that is up but not answering after a tag change is migrating its database: do not restart it, see Pinned image tags. |
WaldurMatrixBotNotRunning |
bot_running failed for 10 minutes while a homeserver URL is set |
warning | docker logs waldur-matrix-bot, see The Matrix bot. |
WaldurMatrixBotSignInFailing |
bot_whoami failed for 10 minutes while the homeserver is reachable |
warning | The appservice registration: docker logs waldur-matrix-register, and Token rotation if the tokens changed. |
WaldurMatrixAppservicePingFailing |
appservice_ping failed for 10 minutes while bot_whoami passes |
warning | That tuwunel can reach the API at the registered URL (http://waldur-mastermind-api:8080 unless WALDUR_MATRIX_APPSERVICE_URL changes it), and docker logs waldur-mastermind-api. |
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. Outside Kubernetes each scrape target is a
Waldur, so several stacks can be targets of the one job above and keep their
alerts apart.
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
container 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.
Apple Silicon
The Matrix component images already publish linux/arm64. The Waldur images may need a local arm64 rebuild because of openportal's native dependency:
1 2 | |
Then in .env: WALDUR_MASTERMIND_IMAGE_TAG=local-arm, WALDUR_HOMEPORT_IMAGE_TAG=local-arm, DOCKER_REGISTRY_PREFIX=. If the arm64 build fails, build with --platform linux/amd64 instead, which runs under QEMU emulation and is slow.
Verifying the add-on
End-to-end smoke after docker compose --profile matrix up -d:
1 2 3 4 5 6 7 8 9 | |
Once waldur-matrix-register has registered the appservice (see Appservice registration above), visit https://${WALDUR_DOMAIN}/projects/<uuid>/manage/?tab=chat as a staff user and click Create chat room. The Manage tabs use query-param URLs (?tab=chat), not path segments — direct paths like /manage/chat/ 404.
Until the appservice is registered, room creation fails with M_UNKNOWN_TOKEN in docker compose logs waldur-mastermind-worker. Check docker logs waldur-matrix-register for why it is not.
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 |
|---|---|
tuwunel_data volume |
The homeserver's database, media included |
waldur_matrix_secrets volume |
The appservice and registration tokens, and BOOTSTRAP_PASSWORD, which registration signs in with |
waldur-db |
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 |
.env |
GLOBAL_SECRET_KEY and FIELD_ENCRYPTION_KEY: the latter, or a key derived from GLOBAL_SECRET_KEY while it is unset (the default), unlocks the recovery keys, the bot's keys, every history export and the secret Constance settings, tokens included, and none of them can be read without it. During a key rotation, FIELD_ENCRYPTION_KEY_FALLBACKS as well. Also WALDUR_TUWUNEL_IMAGE_TAG, and WALDUR_LIVEKIT_KEY and WALDUR_LIVEKIT_SECRET for calls |
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 add-on does not configure it, an
admin has to run the command in the admin room, and it leaves media out. A copy
of the stopped volume needs none of that and includes media. Chat is down while
it is taken; Waldur is not.
1 2 3 4 5 6 7 8 9 | |
The bot is stopped with the homeserver so that the keys in the dump match the
homeserver's copy. --volumes-from tuwunel mounts the container's own volumes,
whatever the Compose project is called; docker compose run -v tuwunel_data:…
mounts a new, empty volume of that name instead. Move the files off the host,
and restrict access to them as to the database: together they hold every token
and the keys to everything encrypted in the dump, 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 waldur.dump grows by
about as much as the exports take up.
LiveKit keeps no state: only WALDUR_LIVEKIT_KEY and WALDUR_LIVEKIT_SECRET
matter, and they are in .env. 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 everything
After losing the host or its disk, restore the whole set. Waldur and the
homeserver then come back at the same point, and nothing needs repairing.
Use the same WALDUR_TUWUNEL_IMAGE_TAG as the backup, or a newer one (see the
downgrade warning under Pinned image tags).
1 2 3 4 5 6 7 8 9 10 11 12 | |
create makes the homeserver's container and its empty volume without starting
it. Do not up the matrix profile before the volume is restored: Tuwunel
would create a new database in it, and the archive cannot be unpacked over that.
Restoring only the homeserver
When only tuwunel_data is lost or damaged, by a failed Tuwunel upgrade for
example, restore it alone:
1 2 3 4 5 6 7 8 9 10 | |
The registration comes back with the volume, so waldur-matrix-register
changes nothing, unless the tokens were rotated after the backup: then it
replaces the registration, as after a rotation (with password login off, see
Rotating with password login off).
Waldur's database is now newer than the homeserver.
Restoring only the homeserver
in Waldur's admin guide says what that breaks: the DELETE above 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
docker exec waldur-mastermind-worker in front, and the repair snippet with
docker exec -i waldur-mastermind-worker waldur shell <<'EOF'.
Restoring only Waldur's database
Restored alone to an older dump, waldur-db is older than the homeserver; see
Restoring only Waldur's database.
Where you can, restore the homeserver from the same backup set as well.
Checking a restore
GET /api/admin/matrix/diagnostics/ (staff) passes every check except
"Public homeserver reachable (browser path)" (see
Troubleshooting) and, without calls, "LiveKit (RTC)
configured"; "History exports" (history_exports) shows 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 waldur-db does not match
FIELD_ENCRYPTION_KEY in .env (or GLOBAL_SECRET_KEY, while that is unset):
then the recovery keys and the bot's keys cannot be read either.
Troubleshooting
waldur-matrix-initfails and Tuwunel does not start: readdocker logs waldur-matrix-init.init_matrix_settingsrefuses appservice tokens in Constance that no deployment seeded, for example from an earlier run of the Setup wizard, and writes nothing. To hand the tokens to compose, clearMATRIX_APPSERVICE_AS_TOKENandMATRIX_APPSERVICE_HS_TOKENunder Administration → Configuration → Matrix chat → Settings, andupagain; otherwise start from a fresh stack.M_UNKNOWN_TOKENin worker logs after a token rotation: the homeserver still holds the registration with the old tokens. Checkdocker logs waldur-matrix-registerand see Token rotation. Registering the descriptor again by hand does not fix it on its own: Tuwunel keeps the old tokens for an id it already has, so unregister first.- Chat drawer says encryption is unavailable in this browser: the browser refused the encryption WebAssembly. The homeport
Content-Security-Policyin theCaddyfilemust keep'wasm-unsafe-eval'inscript-src; it allows compiling WebAssembly only, noteval()of JavaScript. - Calls in encrypted rooms fail after a CSP change: calls encrypt their media in a Web Worker that homeport serves from its own origin (
/assets/e2eeWorker-*.js), soscript-src 'self'in theCaddyfilealready covers it and noworker-srcis set. A customized policy that setsworker-srcmust include'self'. - Browser chat drawer fails to connect: the backend talks to Tuwunel internally at
http://tuwunel.internal:6167(Docker DNS); the browser must reach Tuwunel through Caddy athttps://${WALDUR_DOMAIN}.waldur-matrix-initseeds both — backend usesMATRIX_HOMESERVER_URL, browser-facing endpoints serveMATRIX_HOMESERVER_PUBLIC_URL. If the chat drawer logs CSP errors connecting totuwunel.internal, verifyMATRIX_HOMESERVER_PUBLIC_URLis set:docker exec waldur-mastermind-worker waldur shell -c "from constance import config; print(config.MATRIX_HOMESERVER_PUBLIC_URL)". - A call shows "Could not connect to the call.": confirm
--profile matrix-rtcis active andWALDUR_MATRIX_RTC_ENABLED=true, then check the call token request tohttps://${WALDUR_DOMAIN}/api/matrix/livekit/…in the browser's network tab anddocker compose logs waldur-mastermind-api.403means Waldur refused the caller (not joined to the room, or an unknown device).503means Waldur could not reach the homeserver or LiveKit, or the LiveKit settings are missing: check thatwaldur-matrix-initran with RTC enabled. - Diagnostics shows "Public homeserver reachable" as FAIL even though the chat works: the reachability probe at
/api/admin/matrix/diagnostics/runs from inside the mastermind container. The public URL (https://${WALDUR_DOMAIN}) is a Caddy-proxied address reachable from the browser, not from the backend's network namespace — so the probe getsConnection refused. The "Public homeserver URL configured" check above it confirms the value is set; verify the chat round-trips end-to-end from a browser instead of trusting this single probe. - Communication tab missing on a project: requires three things — the
project.show_matrix_chatfeature flag is on, a Matrix room exists for the project, AND the room cache has populated. The third only happens after the project view is visited at least once in the current session. If you navigate directly to/projects/<uuid>/communication/and get 404, visit/projects/<uuid>/first, then the tab appears in the nav.