Running a real Matrix homeserver has a lot of moving parts: Synapse, Postgres, media storage, TLS/ingress, a well-known config server, and now increasingly an identity provider. Most self-hosting guides walk you through wiring them together by hand, once, for your specific setup.
Synapstack is an attempt to package that whole stack as something
repeatable: a docker compose up way, with every environment-specific
detail collapsed into one .env file, so spinning up a homeserver for
experimentation, or standing up a real one for a small team or org, is a
matter of filling in values.
Under the hood it's:
- Synapse
- Postgres
- S3-compatible media storage Fronted by a Cloudflare Tunnel, with
- Ketesa as the admin dashboard and
.well-knownconfig server - Matrix Authentication Service (MAS) as the identity provider, delegating login to Google.
| Service | Container name | Purpose |
|---|---|---|
config-renderer |
matrix_config_renderer |
One-shot: renders config/templates/*.template into data/synapse/, data/mas/, config/ketesa/ using .env, then exits |
synapse |
matrix_synapse |
The Matrix homeserver itself (official image + synapse-s3-storage-provider), delegates all auth to MAS |
mas |
matrix_auth_service |
Matrix Authentication Service — handles login/registration/sessions for Synapse, see Authentication (MAS) |
db |
matrix_db |
Postgres 15, hosts both the synapse and mas databases |
cloudflared |
matrix_tunnel |
Cloudflare Tunnel — the only way traffic reaches this stack from the internet |
ketesa |
ketesa |
Admin dashboard (users/rooms/media/federation) for Synapse + MAS, plus serves /.well-known/matrix/client config |
None of these services publish a port to the host for real use — the one
exception, ketesa's KETESA_PORT, is view-only for checking its rendered
.well-known output locally; it cannot be used to log in (MAS's OAuth
client registration policy rejects http://localhost URIs). Every service
needs a real HTTPS hostname through the Cloudflare Tunnel to actually work.
This is a linear walkthrough — external accounts first, then .env, then
bring the stack up, then close the loop on the pieces that only exist once
it's running.
./check-prereqs.shChecks for docker, the docker compose plugin, openssl, and pwgen
(used to generate the secrets in step 3), and prints OS-specific install
commands for anything missing.
You need three things from outside this repo before .env can be filled
in completely. cloudflare-setup.md has the full
click-by-click walkthrough for the Cloudflare pieces if you've never done
this before — the summary:
- Cloudflare Tunnel — create one and get its token. You don't need to
configure Public Hostnames yet; that happens in step 5, once you know
your exact
.envvalues. - Cloudflare R2 (or any S3-compatible storage) — create a bucket and a scoped API token for it.
- Google OAuth client — create an OAuth 2.0 client and get its client ID/secret. You won't be able to register the exact redirect URI yet — that's generated by this stack's first boot, so you'll come back and add it in step 5.
- (Optional) SMTP account — for password-reset/notification email.
cp .env.example .envEvery variable has an inline comment explaining what it is and, where relevant, how to generate it. At this point you're combining what you gathered in step 2 with:
- The hostnames you want to use (
SERVER_NAME,MAS_SERVER_NAME,KETESA_SERVER_NAME) — these are just decisions right now, they don't need to resolve to anything yet. - Generated secrets —
pwgen -s 64 1/openssl rand -hex 32, per the comments next to each one.
See Environment variables below for the full field-by-field reference.
docker compose up -d --buildOn first run, config-renderer renders all config from config/templates/
using .env (and generates a couple of values that live outside .env —
see Authentication (MAS)), synapse builds its
image (base Synapse + the S3 storage provider), and db initializes
Postgres, including creating the mas database.
Check logs if anything looks off:
docker compose logs -f synapseNow that the stack has actually rendered its config once, the values that only exist post-boot are available. Run:
./show-endpoints.shSafe to run anytime, before or after docker compose up. Use its output to:
- Add a Public Hostname in Cloudflare per service (Synapse, MAS, Ketesa) — cloudflare-setup.md has the click-by-click steps.
- Add the required WAF custom rule that skips managed WAF/Bot Fight
Mode for those hostnames — also in
cloudflare-setup.md. Skipping this isn't optional: Cloudflare will silently block or challenge Matrix/MAS API traffic without it, causing failures that look like application bugs. - Add the exact Google OAuth redirect URI to the client you created in step 2 (Google Cloud Console → your OAuth client → Authorized redirect URIs).
This stack has no local/password login and no pre-baked admin account — MAS delegates entirely to Google, so a user has to actually exist before anyone can be made admin, and users only get created by logging in:
- Log into a Matrix client (e.g. Element) via "Continue with Google,"
using an account in one of
MAS_ALLOWED_EMAIL_DOMAINS. The homeserver URL to enter is in./show-endpoints.sh's output. This creates the user. - Grant that user admin rights:
This grants two separate things that are easy to conflate: MAS-level admin (access to MAS's own account-management UI/GraphQL) and Synapse-level admin (access to the Synapse Admin API) — MAS's own admin concept doesn't propagate to Synapse's database at all, they're unrelated flags, so both are needed to be a "real" admin of this stack.
./make-admin.sh <localpart> # e.g. ./make-admin.sh emerson (for @emerson:your-server-name)
Ketesa (the admin dashboard) now works at its real HTTPS hostname.
The sections below are reference material for once the stack is running, not additional setup steps.
All variables are documented inline in .env.example. The highlights:
SERVER_NAME— the Matrixserver_name. This becomes part of every user ID (@user:SERVER_NAME) and is federation-visible. Changing it later means every existing user ID changes — treat it as effectively permanent once you have real users.PUBLIC_BASEURL/CLIENT_BASE_URL— the URL clients actually connect to. Can differ fromSERVER_NAME(e.g.matrix.example.comservingexample.comas the server name), but in this stack they're generally the same domain.MAS_SERVER_NAME/MAS_PUBLIC_BASEURL— same idea, for the MAS service. Needs its own public hostname (e.g.auth.example.com).KETESA_SERVER_NAME— the admin dashboard's public hostname (e.g.admin.example.com). Required for logging in — MAS's OAuth client registration policy rejectshttp://localhost, so the host-publishedKETESA_PORTcan only be used to view Ketesa's static output, not log in.SYNAPSE_PORT/MAS_PORT/KETESA_PORT— internal container ports. OnlyKETESA_PORTis published to the host (for local debugging only); the rest are reached over the Docker network bycloudflared. Kept together in.envso it's easy to eyeball that none collide.POSTGRES_PASSWORD— shared by both thesynapseandmasPostgres users/databases. The database names (synapse,mas) are fixed and not configurable — see Database.TUNNEL_TOKEN— from your Cloudflare Tunnel.REGISTRATION_SHARED_SECRET,MACAROON_SECRET_KEY,FORM_SECRET— Synapse's own secrets. Generate withpwgen -s 64 1.MAS_ENCRYPTION_SECRET,MAS_MATRIX_SHARED_SECRET— MAS's secrets. Generate withopenssl rand -hex 32.S3_*— media storage (tested against Cloudflare R2; any S3-compatible endpoint should work).GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET— the upstream OIDC provider MAS uses for login (Synapse no longer talks to Google directly). The exact redirect URI to register with Google is not a.envvalue — run./show-endpoints.shto print it.MAS_ALLOWED_EMAIL_DOMAINS— space-separated, no@prefix. Only Google accounts on these domains can register/login; anyone else is rejected.render.shexpands this into MAS's policy engine config (envsubstcan't loop over a list, so this is the one place a.envvalue gets turned into a YAML block rather than substituted directly).SMTP_*,EMAIL_NOTIF_FROM,EMAIL_APP_NAME— outgoing mail for password resets and notifications, and how it's branded.
One Postgres server (db / matrix_db) hosts two fixed-name databases:
synapse— created automatically by the official Postgres image viaPOSTGRES_DB=synapseindocker-compose.yaml.mas— created bypostgres-init/01-create-mas-db.sh, owned by the samesynapseuser.
Database names are intentionally not configurable via .env — only the
shared POSTGRES_PASSWORD is. This keeps the surface area small for anyone
adapting this stack who isn't deeply familiar with Postgres.
docker-entrypoint-initdb.d scripts (like 01-create-mas-db.sh) only run
once, on a fresh data/postgres volume. If you're adding MAS to a
stack whose Postgres volume already existed, create the database manually:
docker compose exec db psql -U synapse -c 'CREATE DATABASE mas OWNER synapse;'This stack has no local Cloudflare ingress config — cloudflared runs with
just a TUNNEL_TOKEN, meaning the tunnel is dashboard-managed. Quick
reference for the Public Hostname mapping (see step 5 above, and
cloudflare-setup.md for the full walkthrough):
| Public hostname | Points to |
|---|---|
SERVER_NAME / PUBLIC_BASEURL host |
http://matrix_synapse:${SYNAPSE_PORT} |
MAS_SERVER_NAME host |
http://matrix_auth_service:${MAS_PORT} |
KETESA_SERVER_NAME host |
http://ketesa:8080 |
Run ./show-endpoints.sh to get the exact values instead of
hand-substituting those ${VAR} placeholders.
Required, not optional: a WAF custom rule that skips managed WAF/Bot
Fight Mode for these hostnames. Cloudflare's managed rules and Bot Fight
Mode are tuned for browser traffic, and will silently challenge or block
Matrix client/federation API traffic and MAS's OAuth endpoints — an API
client can't solve a JS challenge or CAPTCHA. Without this you'll see
confusing failures (timeouts, blocked requests, failed logins) that look
like bugs in this stack but are actually Cloudflare intercepting the
request before it reaches your containers. Full steps in
cloudflare-setup.md.
Synapse delegates all login, registration, and session handling to MAS —
config/templates/homeserver.yaml.template's matrix_authentication_service
block points at MAS, and Synapse no longer talks to Google OIDC directly.
MAS holds the actual upstream Google OIDC config
(config/templates/mas-config.yaml.template's upstream_oauth2 block) and
enforces the email-domain allowlist via its own policy engine.
The upstream provider's id (embedded in the redirect URI Google calls back
to) is not a .env value on purpose — render.sh generates it once and
persists it to data/mas/google-provider-id, the same pattern used for
MAS's signing key. This is deliberate: that ID is baked into the redirect
URI you register with Google, so an accidental edit in .env would
silently break login and orphan already-linked accounts. Run
./show-endpoints.sh to get the exact redirect URI to register.
Pre-creating a user for later SSO login: an admin can create a user in
Ketesa (Users → Create user, username = the local part only) before that
person ever signs in. When they later sign in with Google, MAS links their
Google identity to that existing account automatically instead of creating
a duplicate or rejecting the login — this only works because
claims_imports.localpart.on_conflict is set to set in
mas-config.yaml.template. The username has to match exactly what MAS
computes from their email (the part before @).
This only works cleanly because this stack starts from an empty database.
If you're applying this same setup to an existing Synapse deployment that
already has real users under Synapse-native OIDC, you can't just swap the
config — you need the official
syn2mas
migration tool to import existing users/devices/sessions/OIDC identity links
into MAS's database first, so people aren't logged out or forced to
re-verify E2EE devices. That's a separate, deliberate step for a production
cutover — back up data/postgres before attempting it.
docker-compose.yaml # service definitions
.env / .env.example # environment-specific config (gitignored: .env)
check-prereqs.sh # host-tool preflight check, run before touching .env
show-endpoints.sh # prints Cloudflare/Google config values, safe to run anytime
clean.sh # wipes all runtime state for a genuine fresh-start test
make-admin.sh # grants MAS + Synapse admin rights to an existing user
cloudflare-setup.md # click-by-click Cloudflare Tunnel + R2 walkthrough for beginners
config/
templates/ # source-of-truth config templates (envsubst'd at container start)
ketesa/ # Ketesa's rendered config (gitignored)
render/ # the config-renderer image (alpine + gettext + openssl)
postgres-init/ # first-boot Postgres init scripts (e.g. creating the mas database)
data/
postgres/ # Postgres data directory (gitignored)
synapse/ # Synapse's rendered config + media store + signing key (gitignored)
mas/ # MAS's rendered config + signing key + generated google-provider-id (gitignored)
TODO.md # planned improvements