An easy way to deploy your own Matrix server with reasonable defaults.
One script. A few questions. Your own communication infrastructure with the ability to federate.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#2dd4bf', 'edgeLabelBackground':'#134e4a', 'nodeTextColor':'#e0f2f1', 'fontFamily':'Inter', 'nodeBorderRadius':'12px', 'clusterBorderRadius':'16px', 'secondaryColor':'#5eead4', 'tertiaryColor':'#99f6e4', 'lineColor':'#5eead4'}}}%%
flowchart TD
subgraph Server["Server"]
direction TB
Caddy["🔐 Caddy (TLS/Proxy)"]
Element["💬 Element Web"]
Synapse["🧠 Synapse (Matrix)"]
PostgreSQL["🐘 PostgreSQL"]
Redis["⚡ Redis Cache"]
Coturn["🔁 Coturn (TURN)"]
LiveKit["📡 LiveKit (SFU)"]
end
User["👤 User"]
User ---|TLS| Caddy
Caddy --- Synapse
Caddy --- Element
Caddy --- LiveKit
Caddy --- Coturn
Synapse --- PostgreSQL
Synapse --- Redis
Synapse --- LiveKit
classDef teal fill:#2dd4bf,stroke:#5eead4,stroke-width:2px,color:#e0f2f1,rx:12px;
classDef mint fill:#134e4a,stroke:#5eead4,stroke-width:2px,color:#e0f2f1,rx:12px;
class Caddy,Synapse,Element,PostgreSQL,Redis,LiveKit,Coturn mint;
class User mint;
style Server stroke:#5eead4,stroke-width:4px,rx:16px,fill:#0f172a;
style User stroke:#5eead4,stroke-width:2px,rx:12px;
After running matrix-wizard.sh you'll have a working Matrix server — the whole stack, containerised and wired together:
| Service | What it does |
|---|---|
| The Matrix server. Handles federation, rooms, messages. | |
| The web client. Served at your domain so anyone can log in from a browser. | |
| Reverse proxy. Handles TLS automatically via Let's Encrypt. | |
| Database for Synapse. Considerably more robust than SQLite for anything beyond a toy. | |
| Shared cache/event store for modules (Hookshot E2EE now, others later). | |
| coturn | TURN server. Relays WebRTC traffic for 1:1 voice and video calls when both sides are behind NAT. |
| SFU (Selective Forwarding Unit). Powers group video calls via Element Call and MatrixRTC. |
✅ Your users authenticate against your identity system (MAS) ✅ Your rooms and events live in your Synapse ✅ Your calls are brokered through your MatrixRTC backend ✅ Your media flows through your LiveKit ✅ Your TURN relay is your coturn ✅ Your call UI is your Element Call deployment
So if Microsoft Teams, Google Meet, Slack disappeared tomorrow, your deployment would continue operating.
Everything runs in Docker Compose. Caddy manages your TLS certificate without you lifting a finger.
The setup wizard currently supports OIDC/OAuth2 SSO out of the box — so users can sign in with Google, Microsoft Entra ID, or any other OIDC-compatible provider.
The wizard supports auto-setup of WhatsApp and Slack as of now, but you can add more bridges manually through following the documentation. Keep an eye on this project for auto-setup of more bridges in future releases.
Self-hosting Matrix is genuinely powerful — your own conversations, data, and server rules. But many "easy" setups expect you to:
- know what a reverse proxy is
- have a fair bit of patience for YAML
- copy environment variables and secrets around
This project makes setup even easier. It doesn't take power away from you — but rather sets things up correctly and then gets out of your way, so you can see exactly what's running and why.
- A Linux server with a public IP address (a cheap VPS works fine)
- A domain name pointed at that server (e.g.
matrix.example.com→ your server's IP) - Docker (Engine 24+ recommended) — install guide
- Docker Compose v2 — comes bundled with recent Docker Desktop and Docker Engine
curl,openssl,python3— standard on most distributions
DNS first. Make sure your DNS A record is live before running setup. Caddy needs to reach Let's Encrypt to issue your certificate, and that requires your domain to already be resolving.
git clone https://github.com/nordwestt/matrix-easy-deploy-kit
cd matrix-easy-deploy-kitThe primary operating model is:
- Ensure host dependencies are installed.
- Edit
deploy.yaml. - Run
bash apply.sh. - If you only want to render config without touching running containers, use
bash apply.sh --no-reconcile-runtime.
For first-time setup on a fresh host, install dependencies non-interactively with:
bash ensure_dependencies.shSupported package managers are detected in this order: apt-get, dnf, then pacman.
Docker itself is installed through the official get.docker.com convenience script so Docker packaging differences stay delegated upstream.
If you want that to happen automatically before apply, use:
bash apply.sh --ensure-dependenciesMinimal flow:
# 1) Ensure host dependencies are present
bash ensure_dependencies.sh
# 2) Edit desired state
$EDITOR deploy.yaml
# 3) Converge generated artifacts, module state, and running services
bash apply.sh
# Or do dependency install + apply in one step
# bash apply.sh --ensure-dependencies
# Render/apply config only, without stop/start
# bash apply.sh --no-reconcile-runtimeBy default, bash apply.sh also attempts non-interactive bootstrap for enabled modules when required generated files are missing.
To skip bootstrap:
bash apply.sh --skip-module-bootstrapbash matrix-wizard.shThe wizard is a convenience layer over the same YAML-first model. It edits deploy.yaml, runs apply, and offers module/user/runtime shortcuts.
For first-time setup without the menu:
bash matrix-wizard.sh --full-setupThe wizard still checks dependencies, but bash ensure_dependencies.sh is the faster non-interactive path when you already know you want a standard install.
For unattended wizard automation, you can optionally set ADMIN_PASSWORD before --full-setup:
export ADMIN_PASSWORD='use-a-long-random-secret'
bash matrix-wizard.sh --full-setup
unset ADMIN_PASSWORDAvoid storing ADMIN_PASSWORD in .env or deploy.yaml.
If you already have a complete deploy.yaml and want to bootstrap the server without the interactive wizard, use this flow:
- Apply the config. This also starts or reconciles the stack by default.
- Wait for Synapse to become healthy.
- Create the initial admin user non-interactively.
Example:
# 1. Render runtime files from deploy.yaml and reconcile runtime
bash apply.sh
# 2. Wait until Synapse is healthy
until [[ "$(docker inspect --format='{{.State.Health.Status}}' matrix_synapse 2>/dev/null)" == "healthy" ]]; do
echo "Waiting for Synapse..."
sleep 5
done
# 3. Load generated values and create the initial admin user
set -o allexport
source ./.env
set +o allexport
bash scripts/create-account.sh \
--username "${ADMIN_USERNAME}" \
--password 'replace-with-a-long-random-password' \
--admin --yesNotes:
bash apply.shnow runs stop/start by default so generated config and running containers stay aligned.- Use
bash apply.sh --no-reconcile-runtimewhen you want render-only behavior. scripts/create-account.shis safe to re-run; if the user already exists in MAS it warns and skips, or updates the password only whenfeatures.local_login_enabledis true.- Keep the admin password out of
deploy.yamland.env. Pass it at execution time or inject it through your automation/secret manager. - Enabled modules are bootstrapped non-interactively during
bash apply.shwhen their required generated config is missing. - Bridge/module enable-disable transitions are reconciled by
bash apply.shby default; use--no-reconcile-runtimeto skip the stop/start step.
Run this after major changes or before release:
# 1) First apply should converge cleanly
bash apply.sh
# 2) Second apply should remain clean/idempotent
bash apply.sh
# 3) Runtime command coverage
bash stop.sh
bash start.sh
bash update.sh
# 4) Repeated module re-apply should not churn
bash matrix-wizard.sh --module hookshot
bash matrix-wizard.sh --module hookshot
bash matrix-wizard.sh --module whatsapp-bridge
bash matrix-wizard.sh --module whatsapp-bridge
bash matrix-wizard.sh --module slack-bridge
bash matrix-wizard.sh --module slack-bridgeFor repeatable test cycles, you can remove running services, Docker resources, and generated repository state while keeping deploy.yaml:
bash uninstall.shNon-interactive mode:
bash uninstall.sh --yesThis cleanup removes generated runtime files like .env, .matrix-easy-deploy/, rendered configs, and module data directories (modules/core/synapse_data, modules/hookshot/hookshot, modules/whatsapp-bridge/whatsapp, modules/slack-bridge/slack).
Backups are configured in deploy.yaml under backup:
backup:
enabled: true
repository:
type: local
path: /var/backups/med-kit
schedule:
enabled: false
calendar: '*-*-* 03:00:00'
persistent: true
retention:
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
keep_yearly: 0The keep_* values are retention settings only. They tell Borg/Borgmatic how many archives to keep when bash backup.sh runs; they do not schedule automatic backups on their own.
If backup.schedule.enabled is true, bash apply.sh installs or updates a systemd timer that runs bash backup.sh automatically. backup.schedule.calendar is passed directly to systemd OnCalendar, and backup.schedule.persistent controls whether missed runs should fire after reboot.
Install prerequisites on the host:
sudo apt-get update
sudo apt-get install -y borgbackup borgmatic ageCreate a backup:
bash backup.shIf scheduling is disabled, backups only run when you call bash backup.sh yourself. Enabling backup.schedule lets the repo manage a systemd timer for you.
To enable the built-in systemd timer, set backup.schedule.enabled: true and run:
bash apply.shUseful status commands:
systemctl status matrix-easy-deploy-backup.timer
systemctl list-timers matrix-easy-deploy-backup.timer
journalctl -u matrix-easy-deploy-backup.serviceList available backups:
bash backup.sh --listThat command prints the archive names you can paste directly into restore.
Restore from an archive:
bash restore.sh --archive <archive-name>For unattended or automation-driven restores, skip the destructive confirmation prompts with:
bash restore.sh --archive <archive-name> --yesYou can also pass a unique archive ID prefix from Borg's bracketed ID if you prefer, for example bash restore.sh --archive 74125a60c0a4a76a.
Behavior notes:
backup.shis a live backup: it leaves services running, takes a logicalpg_dump -Fcof the Synapse PostgreSQL database, copies persisted project/module data, exports Caddy state volumes when present, and then runs borgmatic create/prune/check.restore.shis destructive for runtime state: it stops services, extracts the selected archive, restores persisted state, re-runsbash apply.sh, restores the filesystem payload, recreates the Synapse database from the logical dump, re-runsbash apply.sh, and starts services unless you pass--keep-stopped.- Generated runtime files such as
.envand rendered service configs are not treated as canonical backup inputs; restore rebuilds them fromdeploy.yamland.matrix-easy-deploystate. - Existing logged-in sessions can keep showing rooms or messages that no longer exist on the restored server. Logging out and back in usually resolves that stale client state.
- For encrypted history on a new login, users typically need another verified session or their recovery key/secret storage. Registration tokens are unrelated to restoring message access after a rollback.
- This phase supports only local repository targets (
backup.repository.type: local).
For moving hosts or off-site copies, export the same backup payload as a single compressed archive. Unencrypted archives contain full secrets (deploy.yaml, secrets.yaml, TLS state, database dumps) — use --encrypt for off-site storage.
Create a portable backup (updates the local Borg repo unless you pass --export-only):
bash backup.sh --export ~/med-kit-backup.tar.gz
bash backup.sh --export ~/med-kit-backup.tar.gz.age --encrypt
bash backup.sh --export-only ~/med-kit-backup.tar.gz # stage + export, skip BorgRe-export an older Borg snapshot without re-staging:
bash backup.sh --export-from-archive MED_Backup_2026-01-01T03:00:00 --export ~/snapshot.tar.gzRestore from a portable file (no Borg repo required — works on a fresh clone):
bash restore.sh --file ~/med-kit-backup.tar.gz --yes
bash restore.sh --file ~/med-kit-backup.tar.gz.age --encrypt --yesFresh-host bootstrap:
git clone <repo> && cd matrix-easy-deploy
bash bootstrap-from-backup.sh ~/med-kit-backup.tar.gz.age --encrypt --yesEncryption notes:
- Interactive
--encryptusesage(passphrase prompt). - Set
MED_BACKUP_PASSPHRASEfor non-interactive export/restore (uses OpenSSL AES-256-CBC internally). - Portable archives include Synapse DB dumps, bridge DB dumps when enabled, module state, Caddy volumes, and Tuwunel data when applicable.
If you prefer not to install local dependencies, run the wizard from the published container image:
mkdir -p ./med-kit
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$(pwd)/med-kit:/workspace" \
ghcr.io/nordwestt/matrix-easy-deploy-kit:release-latestWhat this does:
- mounts Docker socket so the wizard can create/manage your Matrix containers on the host,
- mounts
./med-kitso generated config and.envpersist on your machine, - opens the same interactive
matrix-wizard.shflow.
The wizard will ask you:
- Your Matrix domain — something like
matrix.example.com - Your server name — appears in Matrix IDs like
@you:example.com(defaults to the base domain) - Admin username and password
- Whether to allow public registration
- Whether to enable federation
- Whether to enable SSO (OIDC/OAuth2)
- If SSO is enabled: one or more providers (loop: add provider, then optionally add another)
- For each provider: name, issuer URL, client ID, client secret
- For each provider: whether unknown users can auto-register via that provider
- For each provider: optional OIDC claim allowlist (org/group/domain control)
- Whether to install Element Web, and on which domain
- Your LiveKit domain — something like
livekit.example.com(defaults tolivekit.<basedomain>)
matrix.server_implementationselects the server software:synapse(default) ortuwunel(Rust, lower resource use). Set this indeploy.yamlbefore the firstbash apply.sh, or choose it in the setup wizard. Switching implementation on an existing deployment is not supported (no Synapse→Tuwunel migration yet).deploy.yamlis the operator-owned source of truth.bash apply.shreadsdeploy.yamland writes generated runtime artifacts (.env, rendered service configs, module state metadata).- Re-running
bash apply.shis idempotent by default: existing generated secrets are re-used. features.local_login_enabled: falsedisables MAS password login for SSO-only Synapse deployments (Synapse native passwords are always off when MAS is enabled).bash apply.shrejects this unless SSO is enabled and at least one OIDC provider is configured.- Enabled modules converge deterministically: if required generated files are missing, setup runs non-interactively.
- Bridge appservice registrations converge deterministically:
- Synapse: enabled modules are synced into
modules/core/synapse_dataand added toapp_service_config_files; disabled modules are removed from both. - Tuwunel: enabled modules are synced into
modules/core/tuwunel_data/appservices/(Tuwunelappservice_dir); disabled modules are removed from that directory.
- Synapse: enabled modules are synced into
- Hookshot Caddy ingress converges deterministically:
- enabled Hookshot ensures a managed Caddy block,
- disabled Hookshot removes the managed Hookshot block.
- To rotate generated secrets intentionally, use:
bash apply.sh --rotate-secrets
--rotate-secretsis destructive for existing deployments unless you plan migration/restart carefully.
features.auto_join defines which rooms new users are joined to on registration. The same configuration works for both Synapse and Tuwunel. bash apply.sh writes the alias list into the generated server config and automatically provisions the rooms (name, topic, welcome message, handover) via med-admin — no separate setup step required.
rooms: list of room aliases. Each entry may be a plain alias string (for example#welcome:example.com) or an object with:alias(required for objects): local alias name or full aliasname,topic,message: room display name, topic, and a one-time welcome messagehandover: list of local usernames or MXIDs to invite and grant room admin (power level 100) so they can manage the room going forwardfederated: whenfalse(default), the room is public on your server but not open to remote Matrix servers; settrueonly if you intentionally want the room federated
synapse.rooms_for_guests: Synapse-only — auto-join guest accounts too (defaulttruewhen set)
Rooms are created as locally public spaces: any user on your server can join, but they are not world-wide public unless you set federated: true.
Example:
features:
auto_join:
rooms:
- alias: welcome
name: Welcome
topic: Start here for server info and introductions
message: |
Welcome to Example Chat! We hope you enjoy your time here.
handover:
- community-admin
federated: false
- '#announce:example.com'
synapse:
rooms_for_guests: falseWhen rooms is non-empty, bash apply.sh restarts services (by default), waits for the server to respond, then provisions the rooms automatically via med-admin. Use bash apply.sh --skip-auto-join-provision to render config without provisioning (for example in CI).
To re-provision manually or post the welcome message again, use:
bash scripts/med-admin.sh setup-auto-join-rooms --yesAdd --force-message to post the configured welcome message even when the room already has messages.
features.element exposes high-value org-facing Element Web options directly in deploy.yaml. bash apply.sh now writes modules/core/element/config.json from that YAML, so nested branding, support links, integrations, and home-page settings render as real JSON instead of string-substituted fragments.
Use first-class keys for common org needs:
- branding and login assets:
brand,default_theme,branding.* - login UX and SSO entry flow:
disable_custom_urls,disable_guests,disable_3pid_login,disable_login_language_selector,sso_redirect_options - support and compliance links:
help_url,help_encryption_url,help_key_storage_url,notice,terms_and_conditions,report_event - integrations and room discovery:
integrations,room_directory - feature gating:
labs,ui_features - advanced escape hatch:
extra_configdeep-merges last and wins on conflicts
Unset keys keep the repo defaults. Home and welcome customization is URL-only in this phase: host the HTML yourself and point Element at it.
Branded deployment example:
features:
element:
brand: Acme Chat
default_theme: dark
help_url: https://docs.acme.example/chat
branding:
auth_header_logo_url: https://assets.acme.example/element/logo.svg
welcome_background_url:
- https://assets.acme.example/element/bg-1.jpg
- https://assets.acme.example/element/bg-2.jpg
auth_footer_links:
- text: Support
url: https://acme.example/support
- text: Status
url: https://status.acme.example/
notice:
title: Usage policy
description: Company systems only. Activity may be reviewed.
show_once: true
terms_and_conditions:
links:
- text: Acceptable use
url: https://acme.example/aupSSO-first UX example:
features:
local_login_enabled: false
sso:
enabled: true
providers:
- name: Google
issuer: https://accounts.google.com/
client_id: your-client-id
client_secret: your-client-secret
element:
disable_custom_urls: true
ui_features:
registration: false
password_reset: false
sso_redirect_options:
on_welcome_page: true
on_login_page: trueCustom home and welcome pages example:
features:
element:
embedded_pages:
home_url: https://assets.example.com/element/home.html
welcome_url: https://assets.example.com/element/welcome.html
login_for_welcome: falseNotes:
- For Element Desktop, your hosted
home.htmlorwelcome.htmlmay need permissive CORS headers. - To disable Scalar integrations entirely, set
features.element.integrations.enabled: false. - For less common Element settings, use
features.element.extra_configto merge raw config into the generated JSON.
Enable shareable meeting links for people without Matrix accounts (like Google Meet). This deploys a lightweight guest Tuwunel server and a self-hosted Element Call SPA, reusing your existing LiveKit stack.
Requirements:
features.calls.enabled: truefeatures.federation_enabled: true(guests join main-server rooms via federation)features.registration_enabled: falseon the main server (guest registration happens on the isolated guest server only)
Example:
features:
calls:
enabled: true
livekit_domain: livekit.example.com
guest_access:
enabled: true
domain: call.example.com # default: call.{base_domain}
server_name: guest.example.com # default: guest.{base_domain}; MXIDs and client APIWhen enabled, Element Web uses your self-hosted Element Call URL for both registered users and guests (element_call.url and guest_spa_url). Guest accounts get MXIDs like @alice:guest.example.com.
How it works (Google Meet style):
- Employees on your main server create calls/rooms in Element Web and share the guest link with externals.
- Externals open the shared link (for example
https://call.example.com/room/#/!room:example.com?roomId=...&intent=join_existing&viaServers=example.com) — a guest account is created automatically in the background. - Visiting
https://call.example.com/alone shows a simple “join your meeting” page — no login or register buttons. - The guest homeserver allows registration but blocks room creation; only your main server can create rooms/calls.
Important — two different “share” actions in Element Web:
| Action | Link shape | For guests? |
|---|---|---|
| Room header Share (matrix icon) | https://matrix.to/#/!room:server |
No — sends people to Matrix client login |
| Invite to call / guest link (link icon while a call is active) | https://call.example.com/room/#/... |
Yes — opens your Element Call SPA |
The guest call link button appears in Element Web when guest_spa_url is configured (done automatically by apply.sh) and the room allows guest access (public join, or “Ask to join” with feature_ask_to_join). It only shows while a call is active in that room.
Generate a guest link from the shell (e.g. after copying the room ID from Element):
bash scripts/call-link.sh '!AdAczRpx:example.com'Room access for guest calls: Element Call checks the room summary API (MSC3266) before joining. The room must use join rule Public or Ask to join — not invite-only or restricted (common when a room lives under a Space). “Public” in Element’s room directory is not the same as join rule Public. Verify from your server:
bash scripts/guest-call-room-check.sh '!yourroom:matrix.example.com'If join_rule is invite or restricted, change the room in Element → Room settings → Access → Anyone can join or Ask to join, then retry in a fresh browser tab (Element Call caches room summaries). The script checks both the main server and the guest homeserver — Element Call uses the guest path. guest_can_join: false on the main server is normal for this setup (it refers to legacy anonymous Synapse guests, not registered @user:guest-server accounts).
Verify Element Web picked up the config (hard-refresh the client after apply.sh):
curl -s "https://element.example.com/config.json" | jq '.element_call'
# Expect: "url" and "guest_spa_url" both pointing at https://call.example.comAfter changing guest-call settings, restart Caddy so the landing page and branding CSS are picked up:
cd caddy && docker compose -f docker-compose.yml -f docker-compose.guest.yml up -d --force-recreate caddyThe guest Element Call container loads modules/calls/guest/guest-call.css to hide UI that Element Call does not expose in config (SSLA caption, account upsell). Guests only enter a display name and join — accounts remain throwaway.
Element Call config.json (generated at modules/calls/guest/element-call.config.json) is separate from Element Web config. We set homeserver + LiveKit, matrix_rtc_mode: compatibility, device defaults (mic on, camera off), clear ssla, and omit analytics keys (posthog, sentry, rageshake) so telemetry stays off. Guest meeting links also pass confineToRoom=true and header=none (Element Call URL flags).
After changing branding or config, recreate the Element Call container:
bash apply.sh
cd modules/calls && docker compose --profile guest-calls up -d --force-recreate element-callIf Element Call fails to start with a mount error on nginx.conf, Docker may have created that path as a directory on an earlier run. Remove it and re-run apply:
rm -rf modules/calls/guest/nginx.conf
bash apply.shDNS records (all pointing at your VPS):
| Host | Purpose |
|---|---|
call.example.com |
Element Call SPA (meeting links) |
guest.example.com |
Guest Tuwunel (MXIDs and client API) |
Note: if your main server is Tuwunel, call reliability may be reduced because Tuwunel lacks MSC4140 delayed events on the principal server. Synapse main + guest Tuwunel is the recommended combination.
MATRIX_DOMAINis where the server API is hosted (for examplematrix.example.com).SERVER_NAMEis the Matrix identity domain in MXIDs (for example@alice:example.com).
If these are different, federation discovery still starts from SERVER_NAME, so DNS for both names must point to this host (or SERVER_NAME must otherwise serve /.well-known/matrix/* that delegates to your server).
This project now generates Caddy config that serves Matrix endpoints on both hostnames automatically.
Everything else — database passwords, signing keys, TURN secrets, LiveKit API keys, internal secrets — is generated automatically. The wizard also auto-detects your server's public IP for coturn's NAT traversal configuration.
This project configures Synapse oidc_providers, which works with Google and other OIDC-compatible identity providers.
To allow only SSO login, set this in deploy.yaml:
features:
local_login_enabled: false
sso:
enabled: true
providers:
- name: Google
issuer: https://accounts.google.com/
client_id: your-client-id
client_secret: your-client-secretThis renders Synapse password_config.enabled: false. Local Matrix password login is disabled, so users must authenticate through one of the configured SSO providers.
During setup (default: enabled), provide:
- Provider display name (for login UI)
- OIDC issuer URL (Google:
https://accounts.google.com/) - OIDC client ID
- OIDC client secret
- Whether SSO can auto-register unknown users (default: Yes for frictionless onboarding)
- Optional claim allowlist (default: off; enable when you need tighter control)
You can configure multiple providers in one run (for example Google + Okta + Authentik).
When creating the OIDC app in your identity provider, set the redirect/callback URL to:
https://<your-matrix-domain>/_synapse/client/oidc/callback
Example for Google:
- Create an OAuth client in Google Cloud Console
- Add the callback URL above as an authorized redirect URI
- Paste client ID + client secret into the setup wizard
To avoid “any Google user can join”, use one or both controls in the setup wizard:
- Enable Restrict SSO to specific OIDC claim values
- Result: only identities with matching claims are accepted by Synapse (
attribute_requirements).
- Set Allow NEW users to auto-register via SSO? to
No(strict mode)
- Result: only users you pre-create on Synapse can log in via SSO.
Common examples:
- Google Workspace org only: claim
hd, allowed valueyourcompany.com - Group allowlist: claim
groups, allowed value(s) likematrix-users,admins
How matching works in this setup:
- If you enter one allowed value, Synapse gets
valuematching. - If you enter multiple comma-separated values, Synapse gets
one_ofmatching. - Matching is exact.
Generated behavior (conceptually):
- claim=
hd, values=acme.com→attribute_requirements: [{attribute: hd, value: acme.com}] - claim=
groups, values=matrix-users,admins→attribute_requirements: [{attribute: groups, one_of: [matrix-users, admins]}]
hd(Google Workspace hosted domain)- Typical value:
yourcompany.com - Use when: you only want users from your Google Workspace domain.
- Typical value:
groups(group membership; provider-specific)- Typical values:
matrix-users,admins - Use when: you want role/group-based access control.
- Typical values:
email- Typical value:
alice@yourcompany.com - Use when: you want a strict allowlist for specific email addresses.
- Typical value:
tid(Microsoft Entra tenant ID)- Typical value: tenant UUID
- Use when: you only want users from one Entra tenant.
preferred_username(provider-specific username/login)- Typical value:
alice - Use when: provider issues stable usernames and you want to allow specific ones.
- Typical value:
Notes:
- Group-based restrictions only work if your IdP actually includes group claims in OIDC userinfo/token.
- Claim matching is exact (or one-of exact values), so use the exact value your provider emits.
- Some claims (especially
groups) may require extra scopes/provider config. This setup requestsopenid profile emailby default. - If your IdP already restricts users at the provider level (for example, Google OAuth app set to your org only), the default auto-registration flow is usually a good UX/security balance.
Pre-creating means creating local Matrix accounts in advance (for approved people only), then letting SSO users log into those existing accounts.
Advantages:
- Prevents surprise account creation from any user who can pass IdP login.
- Gives tighter onboarding control (who gets access and when).
- Lets you combine IdP checks + explicit local account approval for defense in depth.
Use the helper to create approved accounts (on Synapse with MAS, this registers users in MAS and provisions the server user):
bash scripts/create-account.shFor unattended automation, you can also create a user non-interactively:
bash scripts/create-account.sh --username alice --password 'replace-with-a-long-random-password' --yesTo create an admin account, add the admin flag (Synapse server admin is granted via the MSC3861 admin token; MAS handles authentication only):
bash scripts/create-account.sh --username med-admin --password 'replace-with-a-long-random-password' --admin --yesYou can disable SSO in the wizard if you only want MAS password login.
If features.local_login_enabled: false, password account creation is blocked; users must sign in through configured SSO providers.
matrix-easy-deploy/
│
├── matrix-wizard.sh # The wizard. Start here.
├── start.sh # Bring everything back up
├── stop.sh # Bring everything down (data is preserved)
├── update.sh # Pull latest images and restart
│
├── caddy/
│ ├── docker-compose.yml # Caddy service definition
│ ├── Caddyfile.template # Routing template (rendered during setup)
│ └── Caddyfile # Generated — do not edit by hand
│
├── modules/
│ ├── core/ # The core Matrix stack
│ │ ├── docker-compose.yml # Synapse + Element + PostgreSQL + shared Redis
│ │ ├── synapse/
│ │ │ ├── server.yaml.template
│ │ │ ├── server.yaml # Generated during setup
│ │ │ └── log.config
│ │ └── element/
│ │ ├── config.json.template
│ │ └── config.json # Generated during setup
│ ├── calls/ # Voice and video calling stack
│ │ ├── docker-compose.yml # coturn + LiveKit
│ │ ├── coturn/
│ │ │ ├── turnserver.conf.template
│ │ │ └── turnserver.conf # Generated during setup
│ │ └── livekit/
│ │ ├── livekit.yaml.template
│ │ └── livekit.yaml # Generated during setup
│ ├── hookshot/ # Hookshot bridge (webhooks, GitHub, feeds…)
│ │ ├── docker-compose.yml # Hookshot service definition
│ │ ├── setup.sh # Module setup wizard
│ │ └── hookshot/
│ │ ├── config.yml.template
│ │ ├── config.yml # Generated during module setup
│ │ ├── registration.yml.template
│ │ ├── registration.yml # Generated during module setup
│ │ └── passkey.pem # Generated during module setup (keep private)
│ └── whatsapp-bridge/ # WhatsApp bridge (mautrix-whatsapp)
│ ├── docker-compose.yml # Bridge service definition
│ ├── setup.sh # Module setup wizard
│ └── whatsapp/
│ ├── config.yaml # Generated during module setup
│ └── registration.yaml # Generated during module setup
│ └── slack-bridge/ # Slack bridge (mautrix-slack)
│ ├── docker-compose.yml # Bridge service definition
│ ├── setup.sh # Module setup wizard
│ └── slack/
│ ├── config.yaml # Generated during module setup
│ └── registration.yaml # Generated during module setup
│
└── scripts/
├── lib.sh # Shared shell utilities
├── sso.sh # SSO/OIDC setup helpers (used by matrix-wizard.sh)
├── setup/ # matrix-wizard.sh internals (modularized wizard steps)
│ ├── banner.sh # Intro banner output
│ ├── dependencies.sh # Dependency checks
│ ├── config.sh # Interactive configuration prompts
│ ├── generate.sh # Secrets + template rendering
│ ├── runtime.sh # Docker setup/start + admin bootstrap
│ ├── summary.sh # Final post-setup summary
│ └── modules.sh # --module dispatcher helper
├── med-admin.sh # Thin wrapper for the med-admin Python CLI
├── med_admin.py # Operator admin CLI (bootstrap/list/query/reset/rooms)
└── create-account.sh # Account registration helper (user or admin)
├── ensure_dependencies.sh # Non-interactive host dependency installer
Modules live in modules/. The core stack is itself a module — bridges, bots, and other additions will each have their own directory under modules/ with their own docker-compose.yml and setup.sh.
Redis is provisioned once in modules/core and exposed as a shared internal dependency (matrix_redis) so optional modules can reuse it without spinning up duplicate Redis containers.
By default, modules should use SHARED_REDIS_URL from .env and keep separation via Redis DB indexes and/or key prefixes.
- Single shared Redis: use the core Redis instance (
matrix_redis) unless a module has strict isolation needs. - Per-module DB index: assign each module its own DB index (e.g. Hookshot uses
/1, future modules can use/2,/3, ...). - Key prefixing: if a module shares a DB, prefix keys with
<module>:to avoid collisions. - Env-first wiring: modules should read
SHARED_REDIS_URLand derive module-specific URLs in their setup script. - Escalation rule: split to dedicated Redis only when a module needs separate durability/SLO or creates noisy-neighbor risk.
View logs
docker logs -f matrix_synapse
docker logs -f caddy
docker logs -f matrix_element
docker logs -f matrix_postgres
docker logs -f matrix_redis
docker logs -f matrix_livekit
docker logs -f matrix_coturn
docker logs -f matrix-hookshot # if hookshot module is installed
docker logs -f mautrix-whatsapp # if whatsapp-bridge module is installed
docker logs -f mautrix-slack # if slack-bridge module is installedCreate an account (interactive)
bash scripts/create-account.shThe helper asks for a username, generates a secure temporary password by default (or lets you set a custom one), and can optionally grant Synapse admin privileges. On Synapse deployments with MAS enabled, it registers the user in MAS via mas-cli (password login goes through MAS, not Synapse directly).
For non-interactive use, pass flags instead:
bash scripts/create-account.sh --username alice --password 'replace-with-a-long-random-password' --yesTo create an admin account non-interactively:
bash scripts/create-account.sh --username alice --password 'replace-with-a-long-random-password' --admin --yesAdmin account operations with med-admin
med-admin.sh manages a dedicated operator admin account (med-admin by default). On first use, it bootstraps itself automatically — creating the account, storing credentials in .env, and proceeding with the requested command. If local password login is disabled (SSO-only), bootstrap fails with a clear error; pass --access-token instead.
You can still bootstrap explicitly (for example to choose a custom password):
bash scripts/med-admin.sh bootstrap --password 'replace-with-a-long-random-password'After bootstrap, all admin commands use the stored credentials without extra flags.
List all local accounts
bash scripts/med-admin.sh list-accountsWith optional filtering and pagination:
bash scripts/med-admin.sh list-accounts --filter alice --limit 50List admins only
bash scripts/med-admin.sh list-adminsQuery a specific account
bash scripts/med-admin.sh get-account aliceReset a local account password
bash scripts/med-admin.sh reset-password alice --password 'new-long-random-password' --yesCreate a room (interactive)
bash scripts/med-admin.sh create-roomThis prompts for optional details and creates a private room by default if you do not choose public visibility.
Create a room (non-interactive)
bash scripts/med-admin.sh create-room \
--name "Care Team" \
--alias care-team \
--topic "Clinical coordination" \
--private \
--invite alice \
--invite @bob:example.com \
--yesProvision auto-join rooms from deploy.yaml
Normally bash apply.sh provisions rooms automatically. To run manually (or re-apply after config changes):
bash scripts/med-admin.sh setup-auto-join-rooms --yesReads features.auto_join.rooms from deploy.yaml. Use --deploy-yaml path/to/deploy.yaml for a non-default config file. Use --force-message to post the welcome message even when the room already has messages.
Notes:
--name,--alias,--topic,--invite, and--directare optional.- Visibility defaults to private when omitted (
--publicor--privatecan be passed explicitly). --inviteaccepts either usernames or full MXIDs and can be provided multiple times.
How it works
- On first use, med-admin bootstraps automatically and stores credentials in
.env. Runbootstrapexplicitly to set a custom password. - All subsequent admin commands automatically use the stored
med-admincredentials - No need to pass credentials with each command
- If you need to use a different admin account, override with
--access-tokenor--admin-username/--admin-password
Important: SSO-only deployments
If you have features.local_login_enabled: false in deploy.yaml:
create-account.shandmed-admin bootstrapcannot create password accounts (MAS password login is disabled)- Use SSO to sign in, or temporarily set
local_login_enabled: trueand re-runbash apply.shto bootstrap with a password - If you hit this situation after bootstrap, obtain a token from elsewhere (e.g. Element's token export) and pass it to med-admin with
--access-token
Stop all services (data stays intact in Docker volumes)
bash stop.shStart all services
bash start.shUpdate images to the latest release
bash update.shReload Caddy after editing the Caddyfile
docker exec caddy caddy reload --config /etc/caddy/CaddyfileIf you need to change domains, feature flags, or module enablement:
- Edit
deploy.yamldirectly or usebash matrix-wizard.sh. - Apply changes:
bash apply.sh- Restart services if needed:
bash stop.sh
bash start.shBy default, apply.sh preserves existing generated secrets and re-renders files deterministically from deploy.yaml.
By default, apply.sh also runs stop/start to align running services with updated config. Use apply.sh --no-reconcile-runtime to skip that step.
Need migration details from legacy setup behavior? See MIGRATION.md.
The project is designed to grow. Each optional component (a bridge to Discord, a Telegram bridge, a bot framework) lives in its own module under modules/. When a module is ready, you enable it with:
bash matrix-wizard.sh --module <module-name>You can also install modules from the interactive wizard (bash matrix-wizard.sh → Install/configure module).
This updates module desired state in deploy.yaml, runs apply.sh, then calls the module's own setup.sh for module-specific bootstrap (tokens, registration, etc.).
Hookshot connects your Matrix rooms to external services. Out of the box it enables:
| Feature | How to use |
|---|---|
| Generic webhooks | Invite @hookshot to a room, run !hookshot webhook <name> to get an inbound URL |
| RSS/Atom feeds | !hookshot feed <url> — posts new items to the room |
| Encrypted rooms (E2EE) | Supported out of the box (Hookshot crypto store + Redis cache + Synapse MSC3202/MSC2409 flags) |
| GitHub (optional) | Configure github: block in config.yml, re-run or restart |
| GitLab (optional) | Configure gitlab: block in config.yml |
| Jira (optional) | Configure jira: block in config.yml |
bash matrix-wizard.sh --module hookshotThe wizard will ask for a webhook domain (e.g. hookshot.example.com), generate the appservice tokens and RSA passkey, register Hookshot with Synapse, add a Caddy site block, and start the container automatically.
DNS required: add an A record for your hookshot domain before running the wizard.
After setup:
# View logs
docker logs -f matrix-hookshot
# Enable GitHub / GitLab / Jira — edit config.yml then:
docker restart matrix-hookshotIf you installed Hookshot before encrypted-room support was added, run bash matrix-wizard.sh --module hookshot once more to apply the new Redis and Synapse compatibility settings.
Diagnose wiring issues (checks registration, tokens, network, and does a live Synapse→Hookshot ping):
bash scripts/hookshot-check.shCommand caveats (common gotchas):
- Room commands (
!hookshot ...) require an unencrypted room unless Hookshot encryption support is configured. - Give
@hookshotenough power in the room (typically Moderator / PL50) so it can write room state. - In DMs,
helpmay look sparse if you have only webhooks/feeds enabled and no GitHub/GitLab/Jira auth features configured.
mautrix-whatsapp lets you send and receive WhatsApp messages directly from your Matrix client. Your WhatsApp account is linked by scanning a QR code — no third-party service involved, everything runs on your own server.
| Feature | Notes |
|---|---|
| 1:1 chats | All personal WhatsApp conversations appear as Matrix rooms |
| Group chats | WhatsApp groups bridged as Matrix rooms |
| Media | Images, video, voice messages, documents — all bridged both ways |
| End-to-bridge encryption (E2EE) | Supported; new portal rooms are unencrypted by default (avoids Element device warnings) |
| PostgreSQL | Dedicated database created automatically during setup |
bash matrix-wizard.sh --module whatsapp-bridgeThe wizard will ask for your Matrix admin username and relay mode preference, then handle everything: database creation, config generation, appservice registration with Synapse, and starting the container.
After setup:
- Open a DM with
@whatsappbot:<your-server>in Element - Send
login - Scan the QR code in WhatsApp → Linked Devices → Link a Device
- Your chats will start appearing as Matrix rooms
# View logs
docker logs -f mautrix-whatsapp
# Re-link after logging out of WhatsApp
# (DM @whatsappbot and send 'login' again)
# Restart
docker restart mautrix-whatsappNote: Your WhatsApp mobile app must stay active. If you factory-reset your phone or uninstall WhatsApp, re-run
loginin the bridge DM to re-link.
If you installed the WhatsApp bridge before end-to-bridge encryption was enabled by default, run bash matrix-wizard.sh --module whatsapp-bridge once more to apply the setting.
Element warning: "The sender of the event does not match the owner of the device that sent it" appears on encrypted bridged rooms. That is expected when encryption.default is true — mautrix encrypts ghost-user messages with the bridge bot's device. This setup keeps encryption.default: false so new portal rooms stay unencrypted and avoid the warning; E2EE still works if you enable encryption on a specific room. Rooms that were already created encrypted before this change will keep showing the warning until you leave them and let the bridge recreate unencrypted portals (or log in again). To force encryption on every new portal anyway, set encryption.default: true in modules/whatsapp-bridge/whatsapp/config.yaml and restart the bridge.
If mautrix-whatsapp logs The as_token was not accepted, run:
bash scripts/whatsapp_bridge_check.shThen re-run bash matrix-wizard.sh --module whatsapp-bridge.
mautrix-slack lets you send and receive Slack messages directly from your Matrix client. Your Slack account is linked using a token and cookie from the Slack web app — no third-party service involved, everything runs on your own server.
| Feature | Notes |
|---|---|
| 1:1 chats | All personal Slack DMs appear as Matrix rooms |
| Channels | Slack channels bridged as Matrix rooms |
| Media | Images, files — all bridged both ways |
| PostgreSQL | Dedicated database created automatically during setup |
bash matrix-wizard.sh --module slack-bridgeThe wizard will ask for your Matrix admin username, then handle everything: database creation, config generation, appservice registration with Synapse, and starting the container.
After setup:
- Open a DM with
@slackbot:<your-server>in Element - Send
login token <xoxc-token> <xoxd-cookie> - Your Slack chats will start appearing as Matrix rooms
Getting your Slack token and cookie:
- Login to Slack in your browser
- Open browser devtools → Application → Local Storage
- Find
localConfig_v2→ teams → your team → token (starts withxoxc-) - The
dcookie (starts withxoxd-) is under Cookies for slack.com
# View logs
docker logs -f mautrix-slack
# Restart
docker restart mautrix-slackMore modules coming. Watch this space.
Caddy can't get a certificate
Usually a DNS issue. Check that your domain resolves to your server's IP:
dig +short matrix.example.comIf it doesn't match, wait for DNS to propagate and try again. Caddy logs all certificate activity:
docker logs caddyGroup calls (Element Call) don't connect
Check that LiveKit is running and that your livekit.example.com DNS record is resolving:
docker logs matrix_livekit
curl -I https://livekit.example.comAlso make sure port range 50000–50100/UDP is open in your firewall.
External guests cannot join Element Call links
When features.calls.guest_access.enabled is true, verify the guest stack is running:
docker logs matrix_guest_tuwunel
docker logs matrix_element_call
curl -I https://call.example.com
curl -I https://guest.example.com/_matrix/client/versionsGuest access requires federation on the main server and DNS for the Element Call domain and guest server domain. The guest Tuwunel server federates only with your main server.
If guests register but Element Call says the room is “not joinable” / private, federation is usually fine — the room join rule is wrong for guests. Check:
bash scripts/guest-call-room-check.sh '!yourroom:matrix.example.com'join_rule must be public or knock on the guest homeserver summary (what Element Call fetches), not only on the main server. Invite-only or restricted rooms fail before join. If the main server says public but the guest probe shows join_rule (missing), guest Tuwunel’s federated summary is incomplete — Element Call then shows “not joinable”. apply.sh routes room-summary requests on the guest domain to the main homeserver to avoid this. After bash apply.sh, recreate Caddy:
cd caddy && docker compose -f docker-compose.yml -f docker-compose.guest.yml up -d --force-recreate caddyRe-run guest-call-room-check.sh; the guest probe should then show join_rule: public. Earlier successful attempts may have occurred when the summary request failed and Element Call fell back to a direct join.
1:1 calls fail or audio/video cuts out
This is almost always a TURN / NAT traversal issue. Check that ports 3478 and 5349 (as well as the UDP relay range 49152–49400) are open in your firewall or VPS security group. Verify coturn is running:
docker logs matrix_coturnIf your VPS is behind a cloud NAT (e.g. AWS, GCP), make sure external-ip in modules/calls/coturn/turnserver.conf is set to your actual public IP, not the NAT gateway IP.
Synapse takes a long time to start
On first boot, Synapse runs database migrations. If your VPS is modest, give it a minute or two. The setup wizard polls every 5 seconds and will wait up to 3 minutes.
The admin user wasn't created
If Synapse wasn't responding in time, the wizard prints the manual command:
bash scripts/create-account.sh \
--username admin \
--password <your_password> \
--admin --yesThe REGISTRATION_SHARED_SECRET is in your .env file.
Synapse reports database connection errors
Make sure the matrix_postgres container is healthy before Synapse tries to connect. You can check:
docker inspect matrix_postgres | grep -A 5 Health- Your
.envfile and.matrix-easy-deploy/secrets.yamlcontain database credentials, TURN secrets, LiveKit API keys, and other internal secrets. Keep both private and out of git. - Public registration is off by default. Think carefully before turning it on; an open Matrix server is a spam target.
- OIDC SSO is on by default in the wizard. If you don't want external IdPs, disable SSO during setup.
- Federation is on by default. If you want a private, islands-only server, disable it during setup.
- The Synapse admin API (
/_synapse/admin/) is accessible via Caddy. It requires a valid admin access token to use — the setup just exposes the routing; auth is Synapse's business. - coturn runs with
network_mode: hostso it can bind UDP relay ports directly. Ensure your firewall allows:- TCP/UDP 3478 (TURN)
- TCP/UDP 5349 (TURN over TLS)
- UDP 49152–49400 (TURN relay range)
- UDP 50000–50100 (LiveKit WebRTC media; bound on the host, not via docker-proxy)
Issues, fixes, and module contributions are welcome. If you're adding a new module, follow the pattern in modules/core/ — a docker-compose.yml for services and a setup.sh that sources scripts/lib.sh for prompts and helpers.
You can validate logic and idempotency on a local laptop without a full server deployment.
Install test dependencies once with uv:
uv syncOr without uv: pip install -r requirements-dev.txt
Use ./test as the canonical test runner (wraps uv run pytest when uv is installed). It sets PYTHONPATH and PYTHONPYCACHEPREFIX=.pycache (so bytecode is not written under tests/__pycache__/), and falls back to unittest if neither uv nor pytest is available.
# All tests (quiet)
./test
# Or invoke pytest directly via uv
uv run pytest tests
uv run pytest tests/test_apply.py -k caddy
# Verbose / single file / filter by name
./test -v
./test tests/test_apply.py
./test -k caddy
# Fast loop: skip shell/subprocess integration tests
./test -m "not integration"
# Integration tests only (bash workflows, setup scripts)
./test -m integration
# Coverage report for Python helpers in scripts/
./test --cov=scripts --cov-report=term-missing
uv run pytest tests --cov=scripts --cov-report=term-missing
# unittest fallback (no uv/pytest installed)
python3 run_unittests.py
python3 run_unittests.py -v- Mirror source layout:
scripts/foo.py→tests/test_foo.py - Existing suites use
unittest.TestCase; keep that style when extending them - New isolated cases can be plain
def test_*()functions with fixtures fromtests/conftest.py - Shared deploy trees:
tests/helpers/project_tree.py(build_minimal_project,write_deploy_config) - Shell/subprocess workflow tests must be marked
@pytest.mark.integration(orpytestmark = pytest.mark.integrationat module level)
CI runs uv run pytest tests --cov=scripts on every pull request and push to main (Python 3.10 and 3.12).
For release confidence, run these checks before shipping changes to setup/apply/module scripts:
uv sync && ./testOptional local smoke checks:
# Deterministic apply run with fixed IP
bash apply.sh --server-ip 127.0.0.1
# Verify repeated apply is stable
bash apply.sh --server-ip 127.0.0.1Automated multi-channel releasing is configured via GitHub Actions.
- Push to the
releasebranch to trigger a release run. - The pipeline publishes GitHub Release assets and GHCR images by default.
- Docker Hub and Homebrew publishing are enabled automatically when their repository secrets are configured.
See RELEASING.md for setup details and required secrets.
MIT. Do what you like with it.