Skip to content

Repository files navigation

Synapstack

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-known config server
  • Matrix Authentication Service (MAS) as the identity provider, delegating login to Google.

Services

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.

Setup

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.

1. Install host prerequisites

./check-prereqs.sh

Checks 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.

2. Set up your external accounts

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 .env values.
  • 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.

3. Fill in .env

cp .env.example .env

Every 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.

4. Bring the stack up

docker compose up -d --build

On 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 synapse

5. Close the loop: Cloudflare + Google

Now that the stack has actually rendered its config once, the values that only exist post-boot are available. Run:

./show-endpoints.sh

Safe 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).

6. First login and admin bootstrap

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:

  1. 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.
  2. Grant that user admin rights:
    ./make-admin.sh <localpart>
    # e.g. ./make-admin.sh emerson   (for @emerson:your-server-name)
    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.

Ketesa (the admin dashboard) now works at its real HTTPS hostname.


Reference

The sections below are reference material for once the stack is running, not additional setup steps.

Environment variables

All variables are documented inline in .env.example. The highlights:

  • SERVER_NAME — the Matrix server_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 from SERVER_NAME (e.g. matrix.example.com serving example.com as 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 rejects http://localhost, so the host-published KETESA_PORT can only be used to view Ketesa's static output, not log in.
  • SYNAPSE_PORT / MAS_PORT / KETESA_PORT — internal container ports. Only KETESA_PORT is published to the host (for local debugging only); the rest are reached over the Docker network by cloudflared. Kept together in .env so it's easy to eyeball that none collide.
  • POSTGRES_PASSWORD — shared by both the synapse and mas Postgres 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 with pwgen -s 64 1.
  • MAS_ENCRYPTION_SECRET, MAS_MATRIX_SHARED_SECRET — MAS's secrets. Generate with openssl 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 .env value — run ./show-endpoints.sh to print it.
  • MAS_ALLOWED_EMAIL_DOMAINS — space-separated, no @ prefix. Only Google accounts on these domains can register/login; anyone else is rejected. render.sh expands this into MAS's policy engine config (envsubst can't loop over a list, so this is the one place a .env value 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.

Database

One Postgres server (db / matrix_db) hosts two fixed-name databases:

  • synapse — created automatically by the official Postgres image via POSTGRES_DB=synapse in docker-compose.yaml.
  • mas — created by postgres-init/01-create-mas-db.sh, owned by the same synapse user.

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;'

Exposing services via Cloudflare

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.

Authentication (MAS)

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.

Repo layout

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages