feat(ess-pro/compose): deploy Element Server Suite Pro via Compose

initial commit of the converted role from helm charts for qubernetis to compose ansible role
This commit is contained in:
Tobias Wüst 2026-06-04 10:52:05 +02:00
parent c11f019aae
commit 32eca6b923
33 changed files with 1906 additions and 0 deletions

View file

@ -0,0 +1,229 @@
# Ansible Role: ess_pro_compose
Deploys the full **Element Server Suite Pro v26.5.1** stack as a single docker
compose project, modelled 1:1 on the official `matrix-stack` Helm chart from
Element. Fronted by the existing DMZ Traefik, secrets sourced from OpenBao
(plus locally-generated cryptographic material), same conventions as the
other `digitalboard.core` roles.
> **Licensing note:** ESS Pro is distributed as a Helm/Kubernetes product.
> Running the Pro images under docker compose requires explicit vendor
> agreement, which is in place for this deployment.
## Architecture
12 services, mirroring the chart:
```
┌───────────────┐
┌──────────────────────HTTP──▶│ element-web │
│ └───────────────┘
│ ┌───────────────┐
│ ┌──────────────────HTTP──▶│ element-admin │
│ │ └───────────────┘
│ │ ┌───────────────┐
│ │ ┌───────────────HTTP──▶│ mas │ ─┐
DMZ Traefik ──┤ │ │ └───────────────┘ │ ┌──────────┐
│ │ │ ┌───────────────┐ ├─▶│ postgres │
│ │ │ ┌────────────HTTP──▶│ haproxy │ │ └──────────┘
│ │ │ │ │ (Pro Image) │ │ ┌──────────┐
│ │ │ │ └───┬─────────┬─┘ │ │ redis │
│ │ │ │ │ │ │ └──────────┘
│ │ │ │ ┌─────────────────┘ │ │
│ │ │ │ ▼ ▼ │
│ │ │ │ ┌──────────────┐ ┌────────────────┴───────┐
│ │ │ │ │ synapse-main │◀──▶│ synapse-fed-reader-0..N│
│ │ │ │ │ (Python) │ │ (Rust Pro worker) │
│ │ │ │ └──────────────┘ └────────────────────────┘
│ │ │ │
│ │ │ └──HTTP(/.well-known)──▶ haproxy (same instance)
│ │ │
│ │ └─────HTTP(/sfu/get)──────▶┌──────────────────┐
│ │ │ matrix-rtc-auth │ (lk-jwt)
│ │ └──────────┬───────┘
│ └─HTTP+TCP/30001+UDP/30002───▶┌──────────▼───────┐
│ │ matrix-rtc-sfu │ (LiveKit)
│ └──────────────────┘
└─ HTTPS termination on Traefik, plain HTTP downstream
```
## Hostnames
| Component | Hostname |
| --------------------- | ------------------------------------ |
| Matrix `serverName` | `digitalboard.ch` |
| Synapse (via HAProxy) | `matrix.digitalboard.ch` |
| MAS | `account.digitalboard.ch` |
| Element Web | `chat.digitalboard.ch` |
| Element Admin | `admin.digitalboard.ch` |
| Matrix RTC / Element Call | `mrtc.digitalboard.ch` |
| `.well-known/matrix/` | `digitalboard.ch` (apex) |
Naming follows Element's official docs (`account.*`, `mrtc.*`). Keycloak on
`auth.digitalboard.ch` is untouched.
## Prerequisites
1. Collections on the control node:
```bash
ansible-galaxy collection install community.docker community.hashi_vault
pip install docker hvac
```
2. Target host: Debian bookworm with Docker CE + compose plugin (the shared
digitalboard docker role handles this) and `python3-cryptography`.
3. DMZ Traefik attached to the `proxy` network with a `websecure` entrypoint
and a `letsencrypt` certresolver.
4. DNS A/AAAA records for the apex + five subdomains.
5. DMZ firewall NAT-forwards TCP/`30001` and UDP/`30002` to the host (Element
Call media ports — fixed by the chart, not the wide 50k60k range).
6. ESS Pro registry credentials (and Authentik OIDC client secret) bootstrapped
in OpenBao at `kv/digitalboard/ess-compose` via
`examples/openbao-bootstrap.sh`.
## How secrets work
Two layers:
- **From OpenBao:** Element registry username/token and Authentik OIDC client
secret. Pulled at playbook time via `community.hashi_vault.vault_kv2_get`
lookups, same pattern as the other digitalboard.core roles.
- **Generated locally:** The 14 cryptographic secrets the chart's
`init-secrets` job normally produces (Synapse signing key, MAS RSA/ECDSA
keys, Synapse↔MAS shared secret, replication secret, Postgres passwords,
LiveKit secret, admin user password). A Python script bundled with the role
generates them on first run into `/opt/ess/secrets/` and never overwrites
existing files — runs of the playbook are idempotent. All containers mount
this directory read-only as `/secrets/ess-generated/` (matches the chart's
mount path).
The MAS RSA key is generated in DER PKCS8 format, ECDSA in PEM PKCS8, and the
Synapse signing key in Synapse's native `ed25519 <keyid> <base64>` format.
All formats verified against what the chart's `matrix-tools generate-secrets`
produces.
## Usage
```yaml
# site.yml
- hosts: ess_servers
become: true
roles:
- digitalboard.core.ess_pro_compose
```
```yaml
# inventory/group_vars/ess_servers.yml -- see examples/
ess_server_name: "digitalboard.ch"
ess_synapse_fed_reader_replicas: 5
ess_oidc_enabled: true
ess_oidc_issuer: "https://authentik.digitalboard.ch/application/o/ess/"
ess_rtc_external_ip: "203.0.113.42"
ess_registry_username: "{{ lookup('community.hashi_vault.vault_kv2_get', ...).data.data.registry_username }}"
ess_registry_token: "{{ lookup('community.hashi_vault.vault_kv2_get', ...).data.data.registry_token }}"
ess_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', ...).data.data.oidc_client_secret }}"
```
Run: `ansible-playbook -i inventories/digitalboard site.yml`
The role creates `@localadmin:digitalboard.ch` via `mas-cli` and prints the
location of the generated password (`/opt/ess/secrets/ADMIN_USER_PASSWORD` on
the host).
## Post-deploy verification
```bash
# All containers healthy
docker compose -f /opt/ess/compose.yml ps
# Synapse + MAS<-->Synapse wiring
curl -sS https://matrix.digitalboard.ch/_matrix/client/versions | jq .versions
curl -sS https://digitalboard.ch/.well-known/matrix/server | jq
curl -sS https://digitalboard.ch/.well-known/matrix/client | jq
# MAS sanity
docker compose -f /opt/ess/compose.yml exec mas \
mas-cli --config /conf/mas-config.yaml doctor
# HAProxy stats (internally)
docker compose -f /opt/ess/compose.yml exec haproxy \
wget -qO- http://localhost:8405/metrics | head
```
## Operations
- **Config change:** re-run the playbook. Changed templates trigger
per-component `docker compose restart` via handlers.
- **Image upgrade:** bump `ess_images.<component>` in defaults or group_vars,
re-run.
- **Scale federation-reader:** change `ess_synapse_fed_reader_replicas`, re-run
(HAProxy backend list is rendered from the same variable).
- **Logs:** `docker compose -f /opt/ess/compose.yml logs -f synapse-main`
- **Tear down:** `docker compose -f /opt/ess/compose.yml down -v`
## What's faithful to the chart, what's adapted
**Faithful to chart v26.5.1:**
- All image paths from `registry.element.io` (correct repos: `synapse-onprem`,
`synapse-pro-worker`, `matrix-authentication-service`, `element-web-pro`,
`element-admin`, `haproxy`, `livekit-server-distroless`, `lk-jwt-service`,
`postgres`, `redis-distroless`).
- HAProxy config 1:1 from the chart (path-based routing to fed-reader for
`/event`, `/state`, `/state_ids`, admin IP allow-list, well-known
serving on port 8010, 429.http for queue overflow).
- Synapse `homeserver.yaml` merged from the chart's four fragments
(underrides + overrides + main listeners + log config) with both Pro
modules loaded (`synapse_ess_pro.EssPro`,
`synapse_mass_local_room_upgrades.MassLocalRoomUpgradesModule`).
- MAS config with all four listeners (web 8080, internal 8081, root 8082,
synapse 8083) and `kind: synapse_modern` for delegated auth.
- federation-reader (Rust worker) config in its native schema, not
Synapse-Python-worker syntax.
- LiveKit on TCP 30001 + UDP 30002 muxed, with `node_ip` set for ICE.
- Element Web config with Pro features (`use_exclusively`,
`element-pro` mobile variant).
- Init-secrets bundle generated with matching key types and formats
(rand32 url-safe / hex32 / rsa:4096:der / ecdsaprime256v1 PEM /
Synapse ed25519 signing key).
**Adapted for compose:**
- K8s DNS-SRV service discovery (`_synapse-http._tcp.X.svc.cluster.local`)
replaced with direct compose service names + the embedded DNS resolver
(`127.0.0.11:53`). HAProxy backend entries use plain hostnames.
- StatefulSet PVCs replaced with named docker volumes.
- The chart's `matrix-tools render-config` init-container is replaced by
Ansible Jinja2 template rendering on the control node — same merge order,
no Python interpreter in init-containers.
- The chart's `init-secrets` K8s job is replaced by the local
generate-secrets script.
- Postgres `postgres-ess-updater` sidecar (which re-runs the init script
in case of password changes) is omitted; first-boot init via
`/docker-entrypoint-initdb.d/` is sufficient for compose, since the
generated passwords don't rotate on re-run (idempotent secrets).
- No Synapse Pro autoscaler (K8s HPA only); replica count is static via
`ess_synapse_fed_reader_replicas`.
## Things not yet wired (optional Pro components)
The chart can also deploy these — not included in this role's first pass,
add as needed:
- Hookshot (Matrix bot framework for GitHub/GitLab/JIRA bridges)
- Secure Border Gateway (Federation app-firewall — only relevant if you
federate with strict-control orgs / German TI-Messenger)
- Advanced Identity Management (LDAP/SCIM provisioning)
- AuditBot, AdminBot, supervision
- Sygnal (mobile push gateway)
- Telemetry service (chart deploys this by default; here it's optional)
- Content scanner
Each maps to its own template directory in `charts/matrix-stack/templates/`
and can be added later as additional compose services.

View file

@ -0,0 +1,149 @@
# SPDX-License-Identifier: MIT-0
---
# =============================================================================
# ess_pro_compose role — defaults
# =============================================================================
# Deploys the full ESS Pro stack (matrix-stack chart v26.5.1) as a docker
# compose project, including the Pro federation-reader worker. Same conventions
# as the other digitalboard.core roles. Secrets are sourced from OpenBao.
# -----------------------------------------------------------------------------
# Chart version we're modelling
# -----------------------------------------------------------------------------
ess_chart_version: "26.5.1"
# -----------------------------------------------------------------------------
# Project layout on the target host
# -----------------------------------------------------------------------------
ess_compose_dir: "/opt/ess"
ess_compose_project_name: "ess"
# Where rendered configs and runtime data live (mounted into containers)
ess_compose_conf_dir: "{{ ess_compose_dir }}/conf" # rendered configs
ess_compose_secrets_dir: "{{ ess_compose_dir }}/secrets" # generated secrets (0600)
ess_compose_data_dir: "{{ ess_compose_dir }}/data" # volumes
# -----------------------------------------------------------------------------
# Docker networks
# -----------------------------------------------------------------------------
# Public-facing Traefik network (external, managed by the shared traefik role).
ess_compose_traefik_network: "proxy"
ess_compose_traefik_entrypoint: "websecure"
ess_compose_traefik_certresolver: "letsencrypt"
# Internal network for service-to-service traffic only.
ess_compose_internal_network: "ess_internal"
# -----------------------------------------------------------------------------
# Matrix identity
# -----------------------------------------------------------------------------
# Matrix serverName is the domain part of @user:serverName. Immutable.
ess_server_name: "digitalboard.ch"
# Hostnames. Convention follows the official Element docs (account.*, mrtc.*).
# Override per environment in group_vars if you want different prefixes.
ess_hostnames:
synapse: "matrix.{{ ess_server_name }}" # client + federation, fronts HAProxy
mas: "account.{{ ess_server_name }}" # Matrix Authentication Service
element_web: "chat.{{ ess_server_name }}"
element_admin: "admin.{{ ess_server_name }}"
matrix_rtc: "mrtc.{{ ess_server_name }}" # Element Call SFU + auth
# -----------------------------------------------------------------------------
# Image references (Pro images from registry.element.io, chart 26.5.1)
# -----------------------------------------------------------------------------
# Pin to specific tags for production. The chart bundles digests; we use
# version-aligned tags so they're readable. Override individually as needed.
ess_images:
synapse: "registry.element.io/synapse-onprem:sha-63110a4"
synapse_pro_worker: "registry.element.io/synapse-pro-worker:0.4.0"
mas: "registry.element.io/matrix-authentication-service:1.17.0"
element_web: "registry.element.io/element-web-pro:1.12.18"
element_admin: "registry.element.io/element-admin:1.5.0"
haproxy: "registry.element.io/haproxy:3.2-alpine"
livekit: "registry.element.io/livekit-server-distroless:1.9.1"
lk_jwt: "registry.element.io/lk-jwt-service:0.3.0"
postgres: "registry.element.io/postgres:16-alpine"
postgres_exporter: "registry.element.io/postgres-exporter:0.18.1"
redis: "registry.element.io/redis-distroless:7.4"
matrix_tools: "registry.element.io/matrix-tools:0.17.8"
# -----------------------------------------------------------------------------
# Element registry credentials (from customer.element.io)
# -----------------------------------------------------------------------------
ess_registry_url: "registry.element.io"
ess_registry_username: "" # OpenBao lookup in group_vars
ess_registry_token: "" # OpenBao lookup in group_vars
# -----------------------------------------------------------------------------
# Federation reader worker
# -----------------------------------------------------------------------------
# The Rust-based Pro worker that handles /state, /state_ids, /event federation
# reads. The chart deploys this with 20 replicas; for compose we run it as
# scaled instances.
ess_synapse_fed_reader_replicas: 1
# -----------------------------------------------------------------------------
# Delegated authentication via the digitalboard IdP
# -----------------------------------------------------------------------------
# Authentik in the demo environment, Keycloak in production. Discover the
# exact issuer with:
# curl -s <issuer>/.well-known/openid-configuration | jq .issuer
ess_oidc_enabled: false
ess_oidc_issuer: ""
ess_oidc_client_id: "ess-mas"
ess_oidc_client_secret: "" # OpenBao
ess_oidc_provider_name: "Digitalboard"
ess_oidc_provider_ulid: "01JBADAUTHENTIKDIGITALBOARD01"
ess_oidc_scopes: "openid profile email"
# -----------------------------------------------------------------------------
# Matrix RTC / Element Call (LiveKit SFU)
# -----------------------------------------------------------------------------
# Element's Pro chart fixes RTC to TCP 30001 + UDP 30002 (muxed). Forward
# those on the DMZ firewall to this host.
ess_rtc_tcp_port: 30001
ess_rtc_udp_port: 30002
# Public IP for ICE candidates (the DMZ NAT address). Required.
ess_rtc_external_ip: ""
# LiveKit non-secret key id (the secret comes from the generated bundle).
ess_livekit_key: "matrix-rtc"
# -----------------------------------------------------------------------------
# Registration / federation policy
# -----------------------------------------------------------------------------
ess_enable_registration: false
ess_enable_federation: true # internet federation; turn off for isolated POCs
ess_admin_contact: "mailto:admin@{{ ess_server_name }}"
# -----------------------------------------------------------------------------
# Initial admin user
# -----------------------------------------------------------------------------
# A localadmin user is created on first deploy via mas-cli. The generated
# password lands in {{ ess_compose_secrets_dir }}/ADMIN_USER_PASSWORD.
ess_admin_localpart: "localadmin"
ess_create_admin_user: true
# -----------------------------------------------------------------------------
# Element Admin / Synapse Admin allow-list
# -----------------------------------------------------------------------------
# Source IPs (CIDR) allowed to hit /_synapse/admin/. Default: everyone. Lock
# this down for production (e.g. just the office network + bastion).
ess_admin_allow_ips:
- "0.0.0.0/0"
- "::/0"
# -----------------------------------------------------------------------------
# Resources / sizing (Postgres args)
# -----------------------------------------------------------------------------
# Chart defaults assume a fairly beefy node. Adjust for your VM.
ess_postgres_max_connections: 256
ess_postgres_shared_buffers: "1024MB"
ess_postgres_effective_cache_size: "3840MB"
# -----------------------------------------------------------------------------
# Synapse media store
# -----------------------------------------------------------------------------
ess_synapse_max_upload_size: "100M"
ess_synapse_url_previews_enabled: true

View file

@ -0,0 +1,63 @@
# SPDX-License-Identifier: MIT-0
---
# inventory/group_vars/ess_servers.yml
# Production config: full Pro stack, secrets from OpenBao.
# ---- Matrix identity ----------------------------------------------------
ess_server_name: "digitalboard.ch"
# Default hostnames (matrix./account./chat./admin./mrtc.) inherit from
# ess_server_name. Override `ess_hostnames` here if you need different prefixes.
# ---- Pro worker scaling -------------------------------------------------
# Federation-reader workers (Rust). Chart deploys 20 in K8s with HPA.
# For a 500-700 user vocational school deployment, 3-5 is plenty.
ess_synapse_fed_reader_replicas: 5
# ---- DMZ Traefik integration --------------------------------------------
ess_compose_traefik_network: "proxy"
ess_compose_traefik_entrypoint: "websecure"
ess_compose_traefik_certresolver: "letsencrypt"
# ---- Registration / federation policy -----------------------------------
ess_enable_registration: false
ess_enable_federation: true
# ---- Delegated auth via Authentik (demo) / Keycloak (prod) --------------
ess_oidc_enabled: true
# Verify the actual issuer with:
# curl -s <issuer>/.well-known/openid-configuration | jq .issuer
ess_oidc_issuer: "https://authentik.digitalboard.ch/application/o/ess/"
ess_oidc_client_id: "ess-mas"
ess_oidc_provider_name: "Digitalboard"
# ---- Matrix RTC / Element Call ------------------------------------------
ess_rtc_external_ip: "203.0.113.42" # DMZ public IP — set for your env
# ---- Admin allow-list (lock down for prod!) -----------------------------
ess_admin_allow_ips:
- "10.0.0.0/8" # internal RFC1918
- "172.16.0.0/12"
- "192.168.0.0/16"
- "203.0.113.5/32" # bastion IP
# =============================================================================
# Secrets — from OpenBao (same pattern as bookstack/opnform/homarr)
# =============================================================================
#
# Stored at kv/digitalboard/ess-compose with two keys (registry creds only —
# the cryptographic material is generated locally by the role's
# generate-secrets script and lives in {{ ess_compose_secrets_dir }} on the
# host). The OIDC client secret also lives in OpenBao because it's shared
# with the IdP side.
ess_registry_username: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/ess-compose',
mount_point='kv').data.data.registry_username }}"
ess_registry_token: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/ess-compose',
mount_point='kv').data.data.registry_token }}"
ess_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get',
'digitalboard/ess-compose',
mount_point='kv').data.data.oidc_client_secret }}"

View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Bootstrap the OpenBao entry for ess_pro_compose.
# Only stores the registry credentials and the OIDC client secret —
# the rest of the cryptographic material is generated by the role locally
# on first deploy (and persists in {{ ess_compose_secrets_dir }} on the host).
set -euo pipefail
MOUNT="${MOUNT:-kv}"
PATH_="${PATH_:-digitalboard/ess-compose}"
read -p "Element registry username (from customer.element.io): " REG_USER
read -sp "Element registry token: " REG_TOKEN; echo
read -sp "Authentik OIDC client_secret for ess-mas: " OIDC_SECRET; echo
bao kv put "${MOUNT}/${PATH_}" \
registry_username="${REG_USER}" \
registry_token="${REG_TOKEN}" \
oidc_client_secret="${OIDC_SECRET}"
echo "Done. Verify: bao kv get ${MOUNT}/${PATH_}"

View file

@ -0,0 +1,7 @@
# SPDX-License-Identifier: MIT-0
---
- name: Deploy ESS Pro v26.5.1 (full stack with federation-reader worker)
hosts: ess_servers
become: true
roles:
- digitalboard.core.ess_pro_compose

View file

@ -0,0 +1,42 @@
# SPDX-License-Identifier: MIT-0
---
- name: Restart haproxy
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
services: [haproxy]
state: restarted
- name: Restart synapse-main
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
services: [synapse-main]
state: restarted
- name: Restart synapse-fed-reader
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
state: restarted
- name: Restart mas
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
services: [mas]
state: restarted
- name: Restart matrix-rtc-sfu
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
services: [matrix-rtc-sfu, matrix-rtc-authorisation]
state: restarted
- name: Restart element-web
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
services: [element-web]
state: restarted
- name: Restart redis
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
services: [redis]
state: restarted

View file

@ -0,0 +1,17 @@
# SPDX-License-Identifier: MIT-0
---
galaxy_info:
role_name: ess_pro_compose
author: digitalboard
description: Full ESS Pro stack (matrix-stack v26.5.1) via docker compose, with federation-reader worker
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Debian
versions:
- bookworm
dependencies: []
collections:
- community.docker

View file

@ -0,0 +1,79 @@
# SPDX-License-Identifier: MIT-0
---
# Render every component's configuration. Each template uses _ess_secrets
# facts (loaded in secrets.yml) for password substitution.
- name: Render HAProxy config
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ ess_compose_conf_dir }}/haproxy/{{ item.dest }}"
mode: "0640"
loop:
- { src: haproxy/haproxy.cfg.j2, dest: haproxy.cfg }
- { src: haproxy/429.http.j2, dest: 429.http }
- { src: haproxy/path_map_file.j2, dest: path_map_file }
- { src: haproxy/path_map_file_get.j2, dest: path_map_file_get }
- { src: haproxy/admin-allow-ips.lst.j2, dest: admin-allow-ips.lst }
notify: Restart haproxy
- name: Render well-known files
ansible.builtin.template:
src: "haproxy/well-known/{{ item }}.j2"
dest: "{{ ess_compose_conf_dir }}/haproxy/well-known/{{ item }}"
mode: "0644"
loop:
- server
- client
- support
- element.json
notify: Restart haproxy
- name: Render Synapse configs
ansible.builtin.template:
src: "{{ item.src }}"
dest: "{{ ess_compose_conf_dir }}/synapse/{{ item.dest }}"
mode: "0640"
loop:
- { src: synapse/homeserver.yaml.j2, dest: homeserver.yaml }
- { src: synapse/log_config.yaml.j2, dest: log_config.yaml }
- { src: synapse/federation-reader.yaml.j2, dest: federation-reader.yaml }
no_log: true
notify:
- Restart synapse-main
- Restart synapse-fed-reader
- name: Render MAS config
ansible.builtin.template:
src: mas/config.yaml.j2
dest: "{{ ess_compose_conf_dir }}/mas/config.yaml"
mode: "0640"
no_log: true
notify: Restart mas
- name: Render SFU config
ansible.builtin.template:
src: sfu/config.yaml.j2
dest: "{{ ess_compose_conf_dir }}/sfu/config.yaml"
mode: "0640"
no_log: true
notify: Restart matrix-rtc-sfu
- name: Render Element Web config
ansible.builtin.template:
src: element-web/config.json.j2
dest: "{{ ess_compose_conf_dir }}/element-web/config.json"
mode: "0644"
notify: Restart element-web
- name: Render Postgres init script
ansible.builtin.template:
src: postgres/configure-dbs.sh.j2
dest: "{{ ess_compose_conf_dir }}/postgres/configure-dbs.sh"
mode: "0755"
- name: Render Redis config
ansible.builtin.template:
src: redis/redis.conf.j2
dest: "{{ ess_compose_conf_dir }}/redis/redis.conf"
mode: "0644"
notify: Restart redis

View file

@ -0,0 +1,24 @@
# SPDX-License-Identifier: MIT-0
---
- name: Render compose project file
ansible.builtin.template:
src: compose.yml.j2
dest: "{{ _ess_compose_file }}"
mode: "0640"
- name: Pull all images
community.docker.docker_compose_v2_pull:
project_src: "{{ ess_compose_dir }}"
register: ess_pull_result
- name: Bring the stack up
community.docker.docker_compose_v2:
project_src: "{{ ess_compose_dir }}"
state: present
wait: true
wait_timeout: 300
register: ess_up_result
- name: Show running services
ansible.builtin.debug:
msg: "{{ ess_up_result.services | default([]) | map(attribute='Service') | list }}"

View file

@ -0,0 +1,39 @@
# SPDX-License-Identifier: MIT-0
---
- name: Validate required variables
ansible.builtin.assert:
that:
- ess_server_name | length > 0
- ess_registry_username | length > 0
- ess_registry_token | length > 0
- ess_rtc_external_ip | length > 0
fail_msg: >-
Required variables are missing. Provide ess_server_name,
ess_registry_username, ess_registry_token (OpenBao) and
ess_rtc_external_ip in group_vars/ess_servers.yml.
quiet: true
- name: Validate OIDC variables when OIDC is enabled
ansible.builtin.assert:
that:
- ess_oidc_issuer | length > 0
- ess_oidc_client_secret | length > 0
fail_msg: OIDC enabled but issuer / client_secret missing.
quiet: true
when: ess_oidc_enabled | bool
- name: Prerequisites (docker, networks, dirs, registry login)
ansible.builtin.import_tasks: prereq.yml
- name: Generate / verify the ess-generated secret bundle
ansible.builtin.import_tasks: secrets.yml
- name: Render all component configuration files
ansible.builtin.import_tasks: config.yml
- name: Render compose project file and start the stack
ansible.builtin.import_tasks: deploy.yml
- name: Post-install (create admin user)
ansible.builtin.import_tasks: postinstall.yml
when: ess_create_admin_user | bool

View file

@ -0,0 +1,48 @@
# SPDX-License-Identifier: MIT-0
---
# Create @localadmin via mas-cli, using the ADMIN_USER_PASSWORD generated
# by secrets.yml. Idempotent: mas-cli rejects duplicates, we ignore that.
- name: Read generated admin password
ansible.builtin.slurp:
src: "{{ ess_compose_secrets_dir }}/ADMIN_USER_PASSWORD"
register: _ess_admin_pw_slurp
no_log: true
- name: Check whether the admin user already exists
ansible.builtin.command:
cmd: >
docker compose -f {{ _ess_compose_file }}
exec -T mas
mas-cli --config /conf/mas-config.yaml
manage list-users --filter username={{ ess_admin_localpart }}
register: _ess_admin_check
changed_when: false
failed_when: false
- name: Register admin user (mas-cli)
ansible.builtin.command:
cmd: >
docker compose -f {{ _ess_compose_file }}
exec -T mas
mas-cli --config /conf/mas-config.yaml
manage register-user --yes
--password {{ (_ess_admin_pw_slurp.content | b64decode).strip() | quote }}
--admin
{{ ess_admin_localpart }}
register: _ess_admin_create
changed_when: "'created' in (_ess_admin_create.stdout + _ess_admin_create.stderr) | lower"
failed_when:
- _ess_admin_create.rc != 0
- "'already exists' not in (_ess_admin_create.stdout + _ess_admin_create.stderr) | lower"
no_log: true
when: ess_admin_localpart not in _ess_admin_check.stdout
- name: Login hint
ansible.builtin.debug:
msg: |
Stack is up.
Admin user: @{{ ess_admin_localpart }}:{{ ess_server_name }}
Password is in {{ ess_compose_secrets_dir }}/ADMIN_USER_PASSWORD on this host.
Element Web: https://{{ ess_hostnames.element_web }}
Element Admin: https://{{ ess_hostnames.element_admin }}

View file

@ -0,0 +1,45 @@
# SPDX-License-Identifier: MIT-0
---
- name: Ensure prerequisite packages on the control target
ansible.builtin.apt:
name:
- ca-certificates
- python3-docker
- python3-cryptography
state: present
update_cache: true
- name: Verify docker compose plugin is available
ansible.builtin.command: docker compose version
register: ess_compose_check
changed_when: false
failed_when: ess_compose_check.rc != 0
- name: Create project directory tree
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0750"
owner: root
group: root
loop: "{{ _ess_dirs }}"
- name: Tighten secrets directory permissions
ansible.builtin.file:
path: "{{ ess_compose_secrets_dir }}"
state: directory
mode: "0700"
owner: root
group: root
- name: Ensure the external Traefik proxy network exists
community.docker.docker_network:
name: "{{ ess_compose_traefik_network }}"
state: present
- name: Authenticate against the Element container registry
community.docker.docker_login:
registry_url: "{{ ess_registry_url }}"
username: "{{ ess_registry_username }}"
password: "{{ ess_registry_token }}"
no_log: true

View file

@ -0,0 +1,47 @@
# SPDX-License-Identifier: MIT-0
---
# Generate the ess-generated secret bundle. Mirrors the chart's `init-secrets`
# job, but runs locally on the host. Idempotent — only writes missing files.
- name: Render generate-secrets script
ansible.builtin.template:
src: generate-secrets.py.j2
dest: "{{ ess_compose_dir }}/.generate-secrets.py"
mode: "0700"
- name: Run generate-secrets (creates only what's missing)
ansible.builtin.command:
cmd: "/usr/bin/python3 {{ ess_compose_dir }}/.generate-secrets.py"
register: ess_secrets_run
changed_when: "'CREATED:' in ess_secrets_run.stdout"
- name: Verify every required secret exists
ansible.builtin.stat:
path: "{{ ess_compose_secrets_dir }}/{{ item }}"
register: ess_secret_stat
loop: "{{ _ess_secret_names }}"
failed_when: not ess_secret_stat.stat.exists
- name: Read postgres passwords for config templates (not persisted)
ansible.builtin.slurp:
src: "{{ ess_compose_secrets_dir }}/{{ item }}"
register: ess_password_slurp
loop:
- POSTGRES_ADMIN_PASSWORD
- POSTGRES_SYNAPSE_PASSWORD
- POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD
- SYNAPSE_MACAROON
- SYNAPSE_REGISTRATION_SHARED_SECRET
- SYNAPSE_WORKERS_REPLICATION_SECRET
- MAS_SYNAPSE_SHARED_SECRET
- MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET
- ELEMENT_CALL_LIVEKIT_SECRET
no_log: true
- name: Expose passwords as facts for templates
ansible.builtin.set_fact:
_ess_secrets: "{{ _ess_secrets | default({}) | combine({item.item: (item.content | b64decode).strip()}) }}"
loop: "{{ ess_password_slurp.results }}"
loop_control:
label: "{{ item.item }}"
no_log: true

View file

@ -0,0 +1,304 @@
# {{ ansible_managed }}
# ESS Pro v{{ ess_chart_version }} on docker compose — rendered by ess_pro_compose.
# Topology mirrors the Helm chart: HAProxy fronts all Synapse traffic,
# synapse-main is the Python homeserver, synapse-fed-reader is the Rust Pro
# worker handling federation reads, MAS handles all auth, LiveKit + lk-jwt
# serve Element Call.
name: {{ ess_compose_project_name }}
networks:
{{ ess_compose_traefik_network }}:
external: true
{{ ess_compose_internal_network }}:
driver: bridge
volumes:
postgres_data:
synapse_media:
services:
# ===========================================================================
# Data plane
# ===========================================================================
postgres:
image: {{ ess_images.postgres }}
container_name: postgres
restart: unless-stopped
networks: [ {{ ess_compose_internal_network }} ]
environment:
LC_COLLATE: "C"
LC_CTYPE: "C"
PGDATA: /var/lib/postgresql/data/pgdata
POSTGRES_INITDB_ARGS: "-E UTF8"
POSTGRES_PASSWORD_FILE: /secrets/ess-generated/POSTGRES_ADMIN_PASSWORD
command:
- postgres
- "-c"
- "max_connections={{ ess_postgres_max_connections }}"
- "-c"
- "shared_buffers={{ ess_postgres_shared_buffers }}"
- "-c"
- "effective_cache_size={{ ess_postgres_effective_cache_size }}"
volumes:
- postgres_data:/var/lib/postgresql/data
- {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro
- {{ ess_compose_conf_dir }}/postgres/configure-dbs.sh:/docker-entrypoint-initdb.d/init-ess-dbs.sh:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: {{ ess_images.redis }}
container_name: redis
restart: unless-stopped
networks: [ {{ ess_compose_internal_network }} ]
command: ["/usr/local/etc/redis/redis.conf"]
volumes:
- {{ ess_compose_conf_dir }}/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ===========================================================================
# Synapse (Python main + Rust federation-reader worker)
# ===========================================================================
synapse-main:
image: {{ ess_images.synapse }}
container_name: synapse-main
restart: unless-stopped
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
networks: [ {{ ess_compose_internal_network }} ]
command: ["python3", "-m", "synapse.app.homeserver", "-c", "/conf/homeserver.yaml"]
volumes:
- {{ ess_compose_conf_dir }}/synapse/homeserver.yaml:/conf/homeserver.yaml:ro
- {{ ess_compose_conf_dir }}/synapse/log_config.yaml:/conf/log_config.yaml:ro
- {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro
- synapse_media:/media
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
{% for i in range(ess_synapse_fed_reader_replicas | int) %}
synapse-fed-reader-{{ i }}:
image: {{ ess_images.synapse_pro_worker }}
container_name: synapse-fed-reader-{{ i }}
restart: unless-stopped
depends_on:
synapse-main: { condition: service_healthy }
networks: [ {{ ess_compose_internal_network }} ]
environment:
APP_CONFIG_FILEPATH: /conf/federation-reader.yaml
volumes:
- {{ ess_compose_conf_dir }}/synapse/federation-reader.yaml:/conf/federation-reader.yaml:ro
- {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro
{% endfor %}
# ===========================================================================
# Matrix Authentication Service (4 listeners)
# ===========================================================================
mas:
image: {{ ess_images.mas }}
container_name: mas
restart: unless-stopped
depends_on:
postgres: { condition: service_healthy }
networks:
- {{ ess_compose_internal_network }}
- {{ ess_compose_traefik_network }}
environment:
MAS_CONFIG: /conf/mas-config.yaml
command: ["server", "--no-migrate"]
volumes:
- {{ ess_compose_conf_dir }}/mas/config.yaml:/conf/mas-config.yaml:ro
- {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8081/health"]
interval: 10s
timeout: 5s
retries: 20
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ ess_compose_traefik_network }}"
- "traefik.http.routers.ess-mas.rule=Host(`{{ ess_hostnames.mas }}`)"
- "traefik.http.routers.ess-mas.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-mas.tls=true"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-mas.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.services.ess-mas.loadbalancer.server.port=8080"
# MAS root listener (port 8082) is mounted as a separate Traefik router so
# /.well-known/openid-configuration on the apex of the mas host is reachable.
# We attach a second router on the same service via a path rule.
# ===========================================================================
# HAProxy — fronts all Synapse + well-known traffic
# ===========================================================================
haproxy:
image: {{ ess_images.haproxy }}
container_name: haproxy
restart: unless-stopped
depends_on:
synapse-main: { condition: service_healthy }
networks:
- {{ ess_compose_internal_network }}
- {{ ess_compose_traefik_network }}
command: ["-f", "/usr/local/etc/haproxy/haproxy.cfg", "-dW"]
volumes:
- {{ ess_compose_conf_dir }}/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- {{ ess_compose_conf_dir }}/haproxy/path_map_file:/synapse/path_map_file:ro
- {{ ess_compose_conf_dir }}/haproxy/path_map_file_get:/synapse/path_map_file_get:ro
- {{ ess_compose_conf_dir }}/haproxy/429.http:/synapse/429.http:ro
- {{ ess_compose_conf_dir }}/haproxy/admin-allow-ips.lst:/synapse/admin-allow-ips.lst:ro
- {{ ess_compose_conf_dir }}/haproxy/well-known/server:/well-known/server:ro
- {{ ess_compose_conf_dir }}/haproxy/well-known/client:/well-known/client:ro
- {{ ess_compose_conf_dir }}/haproxy/well-known/support:/well-known/support:ro
- {{ ess_compose_conf_dir }}/haproxy/well-known/element.json:/well-known/element.json:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8406/synapse_ready"]
interval: 15s
timeout: 5s
retries: 20
start_period: 90s
labels:
# matrix.<server> -> HAProxy frontend synapse-http-in (port 8008)
- "traefik.enable=true"
- "traefik.docker.network={{ ess_compose_traefik_network }}"
- "traefik.http.routers.ess-synapse.rule=Host(`{{ ess_hostnames.synapse }}`)"
- "traefik.http.routers.ess-synapse.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-synapse.tls=true"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-synapse.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.routers.ess-synapse.service=ess-synapse"
- "traefik.http.services.ess-synapse.loadbalancer.server.port=8008"
# <server>/.well-known/matrix -> HAProxy well-known-in (port 8010)
- "traefik.http.routers.ess-wellknown.rule=Host(`{{ ess_server_name }}`) && PathPrefix(`/.well-known/matrix`)"
- "traefik.http.routers.ess-wellknown.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-wellknown.tls=true"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-wellknown.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.routers.ess-wellknown.service=ess-wellknown"
- "traefik.http.services.ess-wellknown.loadbalancer.server.port=8010"
# ===========================================================================
# Element Web (browser client)
# ===========================================================================
element-web:
image: {{ ess_images.element_web }}
container_name: element-web
restart: unless-stopped
networks: [ {{ ess_compose_traefik_network }} ]
volumes:
- {{ ess_compose_conf_dir }}/element-web/config.json:/app/config.json:ro
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ ess_compose_traefik_network }}"
- "traefik.http.routers.ess-element-web.rule=Host(`{{ ess_hostnames.element_web }}`)"
- "traefik.http.routers.ess-element-web.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-element-web.tls=true"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-element-web.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.services.ess-element-web.loadbalancer.server.port=8080"
# ===========================================================================
# Element Admin (admin panel)
# ===========================================================================
element-admin:
image: {{ ess_images.element_admin }}
container_name: element-admin
restart: unless-stopped
networks: [ {{ ess_compose_traefik_network }} ]
environment:
SERVER_NAME: "{{ ess_server_name }}"
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ ess_compose_traefik_network }}"
- "traefik.http.routers.ess-element-admin.rule=Host(`{{ ess_hostnames.element_admin }}`)"
- "traefik.http.routers.ess-element-admin.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-element-admin.tls=true"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-element-admin.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.services.ess-element-admin.loadbalancer.server.port=8080"
# ===========================================================================
# Matrix RTC / Element Call (LiveKit SFU + lk-jwt)
# ===========================================================================
matrix-rtc-sfu:
image: {{ ess_images.livekit }}
container_name: matrix-rtc-sfu
restart: unless-stopped
networks:
- {{ ess_compose_internal_network }}
- {{ ess_compose_traefik_network }}
command: ["--config", "/conf/sfu-config.yaml"]
volumes:
- {{ ess_compose_conf_dir }}/sfu/config.yaml:/conf/sfu-config.yaml:ro
# WebRTC media ports — DMZ firewall must NAT-forward these to this host.
ports:
- "{{ ess_rtc_tcp_port }}:{{ ess_rtc_tcp_port }}/tcp"
- "{{ ess_rtc_udp_port }}:{{ ess_rtc_udp_port }}/udp"
labels:
- "traefik.enable=true"
- "traefik.docker.network={{ ess_compose_traefik_network }}"
- "traefik.http.routers.ess-matrix-rtc.rule=Host(`{{ ess_hostnames.matrix_rtc }}`)"
- "traefik.http.routers.ess-matrix-rtc.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-matrix-rtc.tls=true"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-matrix-rtc.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.routers.ess-matrix-rtc.service=ess-matrix-rtc"
- "traefik.http.services.ess-matrix-rtc.loadbalancer.server.port=7880"
matrix-rtc-authorisation:
image: {{ ess_images.lk_jwt }}
container_name: matrix-rtc-authorisation
restart: unless-stopped
depends_on:
matrix-rtc-sfu: { condition: service_started }
networks:
- {{ ess_compose_internal_network }}
- {{ ess_compose_traefik_network }}
environment:
LIVEKIT_URL: "wss://{{ ess_hostnames.matrix_rtc }}"
LIVEKIT_KEY: "{{ ess_livekit_key }}"
LIVEKIT_SECRET_FROM_FILE: /secrets/ess-generated/ELEMENT_CALL_LIVEKIT_SECRET
LIVEKIT_FULL_ACCESS_HOMESERVERS: "{{ ess_server_name }}"
volumes:
- {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro
labels:
# /sfu/get is the JWT token endpoint Element Call hits to join calls.
# It lives on the same host as the SFU but on a different backend.
- "traefik.enable=true"
- "traefik.docker.network={{ ess_compose_traefik_network }}"
- "traefik.http.routers.ess-matrix-rtc-auth.rule=Host(`{{ ess_hostnames.matrix_rtc }}`) && PathPrefix(`/sfu/get`)"
- "traefik.http.routers.ess-matrix-rtc-auth.entrypoints={{ ess_compose_traefik_entrypoint }}"
- "traefik.http.routers.ess-matrix-rtc-auth.tls=true"
- "traefik.http.routers.ess-matrix-rtc-auth.priority=200"
{% if ess_compose_traefik_certresolver | length > 0 %}
- "traefik.http.routers.ess-matrix-rtc-auth.tls.certresolver={{ ess_compose_traefik_certresolver }}"
{% endif %}
- "traefik.http.routers.ess-matrix-rtc-auth.service=ess-matrix-rtc-auth"
- "traefik.http.services.ess-matrix-rtc-auth.loadbalancer.server.port=8080"

View file

@ -0,0 +1,33 @@
{
"bug_report_endpoint_url": "local",
"default_server_config": {
"m.homeserver": {
"base_url": "https://{{ ess_hostnames.synapse }}",
"server_name": "{{ ess_server_name }}"
}
},
"element_call": {
"use_exclusively": true
},
"embedded_pages": {
"login_for_welcome": true
},
"features": {
"feature_element_call_video_rooms": true,
"feature_group_calls": true,
"feature_new_room_decoration_ui": true,
"feature_video_rooms": true
},
"mobile_guide_app_variant": "element-pro",
"setting_defaults": {
"UIFeature.deactivate": false,
"UIFeature.passwordReset": false,
"UIFeature.registration": {{ ess_enable_registration | bool | lower }},
"feature_group_calls": true,
"urlPreviewsEnabled": {{ ess_synapse_url_previews_enabled | bool | lower }},
"urlPreviewsEnabled_e2ee": {{ ess_synapse_url_previews_enabled | bool | lower }}
},
"sso_redirect_options": {
"immediate": false
}
}

View file

@ -0,0 +1,102 @@
#!/usr/bin/env python3
# {{ ansible_managed }}
"""
Generate the ess-generated secret bundle the way the Helm chart's
init-secrets job does. Idempotent: only writes files that don't exist.
Mirrors `matrix-tools generate-secrets` arguments from chart v{{ ess_chart_version }}.
"""
import os
import secrets
import sys
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
SECRETS_DIR = Path("{{ ess_compose_secrets_dir }}")
SECRETS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
def write_if_missing(name, content_bytes):
p = SECRETS_DIR / name
if p.exists():
return False
# Atomic-ish write
tmp = p.with_suffix(p.suffix + ".tmp")
tmp.write_bytes(content_bytes)
os.chmod(tmp, 0o600)
tmp.rename(p)
return True
def rand32():
# `matrix-tools rand32` produces 32 url-safe characters
return secrets.token_urlsafe(24)[:32].encode()
def hex32():
return secrets.token_hex(32).encode()
def rsa_der():
key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
return key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
def ecdsa_prime256v1():
key = ec.generate_private_key(ec.SECP256R1())
return key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
def synapse_signing_key():
# Synapse expects: ed25519 <keyid> <unpadded-base64-seed>
import base64
key = ed25519.Ed25519PrivateKey.generate()
seed = key.private_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PrivateFormat.Raw,
encryption_algorithm=serialization.NoEncryption(),
)
# 4-char keyid like Synapse generates
keyid = secrets.token_hex(2)
b64 = base64.b64encode(seed).rstrip(b"=").decode()
return f"ed25519 a_{keyid} {b64}\n".encode()
SPEC = {
"POSTGRES_ADMIN_PASSWORD": rand32,
"POSTGRES_SYNAPSE_PASSWORD": rand32,
"POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD": rand32,
"SYNAPSE_MACAROON": rand32,
"SYNAPSE_REGISTRATION_SHARED_SECRET": rand32,
"SYNAPSE_WORKERS_REPLICATION_SECRET": rand32,
"SYNAPSE_SIGNING_KEY": synapse_signing_key,
"MAS_SYNAPSE_SHARED_SECRET": rand32,
"MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET": rand32,
"MAS_ENCRYPTION_SECRET": hex32,
"MAS_RSA_PRIVATE_KEY": rsa_der,
"MAS_ECDSA_PRIME256V1_PRIVATE_KEY": ecdsa_prime256v1,
"ELEMENT_CALL_LIVEKIT_SECRET": rand32,
"ADMIN_USER_PASSWORD": rand32,
}
created = []
for name, fn in SPEC.items():
if write_if_missing(name, fn()):
created.append(name)
if created:
print("CREATED:", " ".join(created))
else:
print("NOCHANGE")
sys.exit(0)

View file

@ -0,0 +1,9 @@
HTTP/1.0 429 Too Many Requests
Cache-Control: no-cache
Connection: close
Content-Type: application/json
access-control-allow-origin: *
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Origin, X-Requested-With, Content-Type, Accept, Authorization
{"errcode":"M_UNKNOWN","error":"Server is unavailable"}

View file

@ -0,0 +1,4 @@
# {{ ansible_managed }}
{% for cidr in ess_admin_allow_ips %}
{{ cidr }}
{% endfor %}

View file

@ -0,0 +1,177 @@
# {{ ansible_managed }}
# Adapted from ess-helm chart {{ ess_chart_version }} (ess-haproxy ConfigMap).
# K8s DNS-SRV-based service discovery replaced with direct compose hostnames.
global
maxconn 20000
log stdout format raw local0 info
tune.maxrewrite 4096
stats socket ipv4@127.0.0.1:1999 level admin
dns-accept-family ipv4
defaults
mode http
fullconn 10000
maxconn 10000
log global
option forwardfor if-none
option forwarded
timeout connect 5s
timeout queue 60s
timeout client 900s
timeout http-keep-alive 900s
timeout http-request 10s
timeout server 180s
http-reuse aggressive
default-server maxconn 500
option redispatch
compression algo gzip
compression type text/plain text/html text/xml application/json text/css
hash-type consistent sdbm
# Compose resolves service names via the embedded DNS (127.0.0.11). We point
# HAProxy at it so backend health-checks pick up restarts properly.
resolvers compose-dns
nameserver dns1 127.0.0.11:53
accepted_payload_size 8192
hold timeout 600s
hold refused 600s
frontend prometheus
bind *:8405
http-request use-service prometheus-exporter if { path /metrics }
monitor-uri /haproxy_test
no log
frontend http-blackhole
bind *:8009
http-request deny content-type application/json string '{"errcode": "M_FORBIDDEN", "error": "Blocked"}'
frontend startup
bind *:8406
acl synapse_dead nbsrv(synapse-main) lt 1
monitor-uri /synapse_ready
monitor fail if synapse_dead
# ----------------------------------------------------------------------------
# Synapse traffic — main entrypoint that the DMZ Traefik points at for matrix.*
# ----------------------------------------------------------------------------
frontend synapse-http-in
bind *:8008
errorfile 503 /synapse/429.http
http-request capture hdr(host) len 32
http-request capture req.fhdr(x-forwarded-for) len 64
http-request capture req.fhdr(user-agent) len 200
http-request set-header X-Forwarded-Proto https if !{ hdr(X-Forwarded-Proto) -m found }
http-request set-var(txn.x_forwarded_proto) hdr(x-forwarded-proto)
http-response add-header Strict-Transport-Security max-age=31536000 if { var(txn.x_forwarded_proto) -m str -i "https" }
# Access token extraction (used by upstream rate-limit decisions)
http-request set-var(req.access_token) urlp("access_token") if { urlp("access_token") -m found }
http-request set-var(req.access_token) req.fhdr(Authorization),word(2," ") if { hdr_beg("Authorization") -i "Bearer " }
http-request set-header X-Access-Token %[var(req.access_token)]
http-response set-header Permissions-Policy "interest-cohort=()"
# Admin endpoint IP allow-list
acl is_admin path_reg ^/_synapse/admin/.*
http-request set-var(txn.user_ip) req.fhdr(x-forwarded-for) if { hdr(x-forwarded-for) -m found }
http-request set-var(txn.user_ip) src if !{ hdr(x-forwarded-for) -m found }
acl allow_ip_admin var(txn.user_ip) -m ip -f /synapse/admin-allow-ips.lst
http-request deny if !allow_ip_admin is_admin
# FOSS-worker path maps (empty by default; reserved for advanced worker splits)
acl has_get_map path -m reg -M -f /synapse/path_map_file_get
http-request set-var(req.backend) path,map_reg(/synapse/path_map_file_get,main) if has_get_map METH_GET
http-request set-var(req.backend) path,map_reg(/synapse/path_map_file,main) unless { var(req.backend) -m found }
# Pro federation-reader worker: takes /event, /state, /state_ids reads
acl has_available_pro_fed nbsrv('synapse-pro-federation-api-requests') ge 1
http-request set-var(req.backend) str('pro-federation-api-requests') if has_available_pro_fed { path -m reg ^/_matrix/federation/v1/event/ }
http-request set-var(req.backend) str('pro-federation-api-requests') if has_available_pro_fed { path -m reg ^/_matrix/federation/v1/state/ }
http-request set-var(req.backend) str('pro-federation-api-requests') if has_available_pro_fed { path -m reg ^/_matrix/federation/v1/state_ids/ }
# CORS preflight short-circuits
acl rendezvous path_beg /_matrix/client/unstable/org.matrix.msc4108/rendezvous
acl rendezvous path_beg /_synapse/client/rendezvous
use_backend return_204_rendezvous if { method OPTIONS } rendezvous
use_backend return_204_synapse if { method OPTIONS }
# Failover from pro-fed-reader to main if the worker is unavailable
acl has_failover var(req.backend) -m str "pro-federation-api-requests"
acl backend_unavailable str(),concat('synapse-',req.backend),nbsrv lt 1
use_backend synapse-main-failover if has_failover backend_unavailable
use_backend synapse-%[var(req.backend)]
backend synapse-main
default-server maxconn 250
option httpchk
http-check connect port 8080
http-check send meth GET uri /health
server main synapse-main:8008 check port 8080 resolvers compose-dns
backend synapse-main-failover
default-server maxconn 250
option httpchk
http-check connect port 8080
http-check send meth GET uri /health
server main synapse-main:8008 check port 8080 resolvers compose-dns
backend synapse-pro-federation-api-requests
option httpchk
http-check connect port 8008
http-check send meth GET uri /health/alive
balance uri whole
# The federation-reader worker is a Rust service speaking h2c.
{% for i in range(ess_synapse_fed_reader_replicas | int) %}
server fed-reader-{{ i }} synapse-fed-reader-{{ i }}:8008 check resolvers compose-dns proto h2
{% endfor %}
backend return_204_synapse
http-request return status 204 hdr "Access-Control-Allow-Origin" "*" hdr "Access-Control-Allow-Methods" "GET, HEAD, POST, PUT, DELETE, OPTIONS" hdr "Access-Control-Allow-Headers" "Origin, X-Requested-With, Content-Type, Accept, Authorization, Date" hdr "Access-Control-Expose-Headers" "Synapse-Trace-Id, Server"
backend return_204_rendezvous
http-request return status 204 hdr "Access-Control-Allow-Origin" "*" hdr "Access-Control-Allow-Methods" "GET, HEAD, POST, PUT, DELETE, OPTIONS" hdr "Access-Control-Allow-Headers" "Origin, Content-Type, Accept, Content-Type, If-Match, If-None-Match" hdr "Access-Control-Expose-Headers" "Synapse-Trace-Id, Server, ETag"
# ----------------------------------------------------------------------------
# Well-known — served at the apex domain via the same HAProxy.
# DMZ Traefik routes Host=`{{ ess_server_name }}` && PathPrefix(/.well-known) here.
# ----------------------------------------------------------------------------
frontend well-known-in
bind *:8010
acl is_delete_put_post_method method DELETE POST PUT
http-request deny status 405 if is_delete_put_post_method
acl well-known path /.well-known/matrix/server
acl well-known path /.well-known/matrix/client
acl well-known path /.well-known/matrix/support
acl well-known path /.well-known/element/element.json
http-request redirect code 301 location https://{{ ess_hostnames.element_web }} unless well-known
use_backend well-known-static if well-known
default_backend well-known-no-match
backend well-known-static
mode http
http-after-response set-header X-Frame-Options SAMEORIGIN
http-after-response set-header X-Content-Type-Options nosniff
http-after-response set-header X-XSS-Protection "1; mode=block"
http-after-response set-header Content-Security-Policy "frame-ancestors 'self'"
http-after-response set-header X-Robots-Tag "noindex, nofollow, noarchive, noimageindex"
http-after-response set-header Access-Control-Allow-Origin *
http-after-response set-header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
http-after-response set-header Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization"
http-request return status 200 content-type "application/json" file "/well-known/server" if { path /.well-known/matrix/server }
http-request return status 200 content-type "application/json" file "/well-known/client" if { path /.well-known/matrix/client }
http-request return status 200 content-type "application/json" file "/well-known/support" if { path /.well-known/matrix/support }
http-request return status 200 content-type "application/json" file "/well-known/element.json" if { path /.well-known/element/element.json }
backend well-known-no-match
mode http
http-request deny status 404
backend return_500
http-request deny deny_status 500

View file

@ -0,0 +1,5 @@
# {{ ansible_managed }}
# Map matrix paths to worker backends. Format: path_regexp backend_name
# Chart default: empty (no FOSS-worker splits). Reserved for advanced
# worker topologies; the Pro federation-reader routing is hard-coded in
# haproxy.cfg via the synapse-pro-federation-api-requests backend.

View file

@ -0,0 +1,2 @@
# {{ ansible_managed }}
# GET-only worker path map. See path_map_file for context.

View file

@ -0,0 +1,11 @@
{
"m.homeserver": {
"base_url": "https://{{ ess_hostnames.synapse }}"
},
"org.matrix.msc4143.rtc_foci": [
{
"livekit_service_url": "https://{{ ess_hostnames.matrix_rtc }}",
"type": "livekit"
}
]
}

View file

@ -0,0 +1 @@
{"m.server": "{{ ess_hostnames.synapse }}:443"}

View file

@ -0,0 +1 @@
{}

View file

@ -0,0 +1,114 @@
## {{ ansible_managed }}
## Matrix Authentication Service — merged from chart fragments.
## Adapted from ess-helm {{ ess_chart_version }} for docker compose.
http:
public_base: "https://{{ ess_hostnames.mas }}/"
issuer: "https://{{ ess_hostnames.mas }}/"
listeners:
# Public web UI + OAuth + GraphQL + admin API. Fronted by DMZ Traefik.
- name: web
binds:
- host: 0.0.0.0
port: 8080
resources:
- name: human
- name: oauth
- name: assets
- name: graphql
undocumented_oauth2_access: true
- name: adminapi
# Internal — never exposed publicly. Used for healthchecks and metrics.
- name: internal
binds:
- host: 0.0.0.0
port: 8081
resources:
- name: health
- name: prometheus
- name: connection-info
# Root domain — serves .well-known/openid-configuration et al. on
# https://{{ ess_hostnames.mas }} root. Mounted as the public listener
# since DMZ Traefik strips paths.
- name: root
binds:
- host: 0.0.0.0
port: 8082
resources:
- name: discovery
- name: compat
# Talks to Synapse on the internal network only.
- name: synapse
binds:
- host: 0.0.0.0
port: 8083
resources:
- name: discovery
- name: oauth
database:
uri: "postgresql://matrixauthenticationservice_user:{{ _ess_secrets.POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD }}@postgres:5432/matrixauthenticationservice?sslmode=prefer&application_name=matrix-authentication-service"
telemetry:
metrics:
exporter: prometheus
matrix:
homeserver: "{{ ess_server_name }}"
secret_file: {{ _ess_secret_mount }}/MAS_SYNAPSE_SHARED_SECRET
endpoint: "http://synapse-main:8008"
kind: synapse_modern
# ---- OAuth2 clients -------------------------------------------------------
clients:
# Matrix-tools admin client used by mas-cli operations.
- client_id: "000000000000000MATR1XT001S"
client_auth_method: client_secret_basic
client_secret_file: {{ _ess_secret_mount }}/MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET
# ---- Signing keys & encryption (file-mounted) ----------------------------
secrets:
encryption_file: {{ _ess_secret_mount }}/MAS_ENCRYPTION_SECRET
keys:
- key_file: {{ _ess_secret_mount }}/MAS_RSA_PRIVATE_KEY
- key_file: {{ _ess_secret_mount }}/MAS_ECDSA_PRIME256V1_PRIVATE_KEY
# ---- Policy ---------------------------------------------------------------
policy:
data:
admin_clients:
- "000000000000000MATR1XT001S"
admin_users: []
client_registration:
allow_host_mismatch: false
allow_insecure_uris: false
account:
password_registration_enabled: {{ ess_enable_registration | bool | lower }}
passwords:
enabled: true
{% if ess_oidc_enabled %}
# ---- Upstream OIDC (Authentik for demo, Keycloak for prod) ----------------
upstream_oauth2:
providers:
- id: "{{ ess_oidc_provider_ulid }}"
human_name: "{{ ess_oidc_provider_name }}"
issuer: "{{ ess_oidc_issuer }}"
client_id: "{{ ess_oidc_client_id }}"
client_secret: "{{ ess_oidc_client_secret }}"
token_endpoint_auth_method: client_secret_basic
scope: "{{ ess_oidc_scopes }}"
claims_imports:
localpart:
action: require
template: "{{ '{{ user.preferred_username }}' }}"
displayname:
action: suggest
template: "{{ '{{ user.name }}' }}"
email:
action: suggest
template: "{{ '{{ user.email }}' }}"
set_email_verification: always
{% endif %}

View file

@ -0,0 +1,30 @@
#!/bin/sh
# {{ ansible_managed }}
# Postgres init script — chart-equivalent of configure-dbs.sh.
# Reads password files from /secrets/ess-generated and creates two DBs.
set -e
create_or_ensure_db() {
user="$1"
db="$2"
password="$3"
admin_password="$4"
if echo -n "$admin_password" | psql -W -U postgres -tc "SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '$user'" | grep -q 1; then
echo -n "$admin_password" | psql -W -U postgres -c "ALTER USER $user PASSWORD '$password'"
else
echo -n "$admin_password" | psql -W -U postgres -c "CREATE ROLE $user LOGIN PASSWORD '$password'"
fi
if ! echo -n "$admin_password" | psql -W -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$db'" | grep -q 1; then
echo -n "$admin_password" | createdb --encoding=UTF8 --locale=C --template=template0 --owner=$user $db -U postgres
fi
}
POSTGRES_PASSWORD="$(cat /secrets/ess-generated/POSTGRES_ADMIN_PASSWORD)"
ESS_SYNAPSE_PW="$(cat /secrets/ess-generated/POSTGRES_SYNAPSE_PASSWORD)"
ESS_MAS_PW="$(cat /secrets/ess-generated/POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD)"
create_or_ensure_db "matrixauthenticationservice_user" "matrixauthenticationservice" "$ESS_MAS_PW" "$POSTGRES_PASSWORD"
create_or_ensure_db "synapse_user" "synapse" "$ESS_SYNAPSE_PW" "$POSTGRES_PASSWORD"

View file

@ -0,0 +1,27 @@
# {{ ansible_managed }}
# Redis config — adapted from ess-helm {{ ess_chart_version }}. Used as
# pub/sub for Synapse worker replication; no persistence needed.
protected-mode no
port 6379
tcp-backlog 511
tcp-keepalive 300
timeout 0
daemonize no
supervised no
loglevel notice
logfile ''
databases 16
always-show-logo no
stop-writes-on-bgsave-error yes
save ''
# Disable persistence — Synapse uses Redis only for pub/sub between workers.
appendonly no
maxmemory 256mb
maxmemory-policy allkeys-lru
hz 1
dynamic-hz yes
jemalloc-bg-thread yes

View file

@ -0,0 +1,32 @@
## {{ ansible_managed }}
## LiveKit SFU — adapted from ess-helm {{ ess_chart_version }}.
port: 7880
prometheus:
port: 6789
logging:
level: info
pion_level: error
json: false
rtc:
use_external_ip: false
tcp_port: {{ ess_rtc_tcp_port }}
udp_port: {{ ess_rtc_udp_port }}
# Public IP that LiveKit advertises in ICE candidates. The DMZ NAT forwards
# {{ ess_rtc_tcp_port }}/TCP and {{ ess_rtc_udp_port }}/UDP to this host.
node_ip: "{{ ess_rtc_external_ip }}"
# Keys are embedded directly (rendered at compose-up time). The single key
# `{{ ess_livekit_key }}` matches what the authorisation service issues
# tokens against.
keys:
{{ ess_livekit_key }}: "{{ _ess_secrets.ELEMENT_CALL_LIVEKIT_SECRET }}"
room:
auto_create: false
turn:
enabled: false

View file

@ -0,0 +1,23 @@
## {{ ansible_managed }}
## synapse-pro-worker (Rust) federation reader.
## This is a different config schema than Python Synapse.
http:
bind_addr: "::"
bind_port: 8008
metrics:
bind_addr: "::"
bind_port: 9001
synapse:
server_name: "{{ ess_server_name }}"
database:
connection_string: "postgresql://synapse_user:{{ _ess_secrets.POSTGRES_SYNAPSE_PASSWORD }}@postgres:5432/synapse?sslmode=prefer"
redis:
host: redis
port: 6379
logging: basic

View file

@ -0,0 +1,159 @@
## {{ ansible_managed }}
## Synapse homeserver config — merged from chart fragments
## 01-homeserver-underrides + 04-homeserver-overrides + 05-main.
## Adapted from ess-helm {{ ess_chart_version }} for docker compose.
server_name: "{{ ess_server_name }}"
public_baseurl: "https://{{ ess_hostnames.synapse }}/"
web_client_location: "https://{{ ess_hostnames.element_web }}/"
admin_contact: "{{ ess_admin_contact }}"
pid_file: /data/homeserver.pid
signing_key_path: {{ _ess_secret_mount }}/SYNAPSE_SIGNING_KEY
macaroon_secret_key_path: {{ _ess_secret_mount }}/SYNAPSE_MACAROON
registration_shared_secret_path: {{ _ess_secret_mount }}/SYNAPSE_REGISTRATION_SHARED_SECRET
worker_replication_secret_path: {{ _ess_secret_mount }}/SYNAPSE_WORKERS_REPLICATION_SECRET
log_config: "/conf/log_config.yaml"
enable_metrics: true
report_stats: false
# ---- Listeners (from 05-main.yaml) ----------------------------------------
listeners:
- port: 8008
tls: false
type: http
bind_addresses: ['0.0.0.0', '::']
x_forwarded: true
resources:
- names: [client, federation]
compress: false
- port: 9093
tls: false
type: http
bind_addresses: ['0.0.0.0', '::']
x_forwarded: false
resources:
- names: [replication]
compress: false
- port: 8080
tls: false
type: http
bind_addresses: ['0.0.0.0', '::']
x_forwarded: false
resources:
- names: [health]
compress: false
- type: metrics
port: 9001
bind_addresses: ['::']
enable_media_repo: true
media_store_path: "/media/media_store"
max_upload_size: "{{ ess_synapse_max_upload_size }}"
# ---- Pro modules ----------------------------------------------------------
modules:
- module: "synapse_ess_pro.EssPro"
config:
version_path: /ess/version
- module: "synapse_mass_local_room_upgrades.MassLocalRoomUpgradesModule"
config: {}
# ---- Database -------------------------------------------------------------
database:
name: psycopg2
args:
user: synapse_user
password: "{{ _ess_secrets.POSTGRES_SYNAPSE_PASSWORD }}"
dbname: synapse
host: postgres
port: 5432
sslmode: prefer
keepalives: 1
keepalives_idle: 10
keepalives_interval: 10
keepalives_count: 3
cp_min: 5
cp_max: 10
# ---- Redis (required for workers) -----------------------------------------
redis:
enabled: true
host: redis
port: 6379
# Replication topology — fed-reader connects back to the main on 9093.
instance_map:
main:
host: synapse-main
port: 9093
# ---- Matrix 2.0 features (MSC4108 QR login, MSC4222 syncv2, MSC4143 RTC) --
experimental_features:
msc4143_enabled: true
msc4222_enabled: true
msc4108_enabled: true
msc4028_push_encrypted_events: true
# ---- Delegated auth to MAS (stable since Synapse 1.118) -------------------
matrix_authentication_service:
enabled: true
secret_path: {{ _ess_secret_mount }}/MAS_SYNAPSE_SHARED_SECRET
endpoint: "http://mas:8083/"
force_http2: true
password_config:
localdb_enabled: false
enabled: false
# ---- Matrix RTC (Element Call discovery) ----------------------------------
matrix_rtc:
transports:
- type: livekit
livekit_service_url: "https://{{ ess_hostnames.matrix_rtc }}"
# ---- URL previews ---------------------------------------------------------
url_preview_enabled: {{ ess_synapse_url_previews_enabled | bool | lower }}
url_preview_ip_range_whitelist: []
url_preview_ip_range_blacklist:
- '127.0.0.0/8'
- '10.0.0.0/8'
- '172.16.0.0/12'
- '192.168.0.0/16'
- '100.64.0.0/10'
- '169.254.0.0/16'
- '::1/128'
- 'fe80::/10'
- 'fc00::/7'
# ---- Federation -----------------------------------------------------------
{% if ess_enable_federation %}
send_federation: true
federation_client_minimum_tls_version: '1.2'
{% else %}
send_federation: false
federation_domain_whitelist: []
{% endif %}
# ---- Other defaults from chart underrides ---------------------------------
require_auth_for_profile_requests: true
presence:
enabled: false
start_pushers: true
max_event_delay_duration: 24h
room_list_publication_rules:
- action: allow
user_id: "@*:{{ ess_server_name }}"
rc_message:
per_second: 0.5
burst_count: 30
rc_delayed_event_mgmt:
per_second: 1
burst_count: 20
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true

View file

@ -0,0 +1,16 @@
## {{ ansible_managed }}
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
level: INFO
root:
level: INFO
handlers: [console]
disable_existing_loggers: false

View file

@ -0,0 +1,46 @@
# SPDX-License-Identifier: MIT-0
---
# Internal — do not override in inventory.
# Mount points inside containers (Element Pro convention)
_ess_secret_mount: "/secrets/ess-generated"
_ess_conf_mount: "/conf"
_ess_well_known_mount: "/well-known"
# Compose file path
_ess_compose_file: "{{ ess_compose_dir }}/compose.yml"
_ess_env_file: "{{ ess_compose_dir }}/.env"
# Directory tree to create on the host
_ess_dirs:
- "{{ ess_compose_dir }}"
- "{{ ess_compose_conf_dir }}"
- "{{ ess_compose_conf_dir }}/haproxy"
- "{{ ess_compose_conf_dir }}/haproxy/well-known"
- "{{ ess_compose_conf_dir }}/synapse"
- "{{ ess_compose_conf_dir }}/mas"
- "{{ ess_compose_conf_dir }}/sfu"
- "{{ ess_compose_conf_dir }}/element-web"
- "{{ ess_compose_conf_dir }}/postgres"
- "{{ ess_compose_conf_dir }}/redis"
- "{{ ess_compose_secrets_dir }}"
- "{{ ess_compose_data_dir }}"
- "{{ ess_compose_data_dir }}/postgres"
- "{{ ess_compose_data_dir }}/synapse-media"
# All Element Pro secret-file names (matches the init-secrets job in the chart)
_ess_secret_names:
- POSTGRES_ADMIN_PASSWORD
- POSTGRES_SYNAPSE_PASSWORD
- POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD
- SYNAPSE_MACAROON
- SYNAPSE_REGISTRATION_SHARED_SECRET
- SYNAPSE_WORKERS_REPLICATION_SECRET
- SYNAPSE_SIGNING_KEY
- MAS_SYNAPSE_SHARED_SECRET
- MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET
- MAS_ENCRYPTION_SECRET
- MAS_RSA_PRIVATE_KEY
- MAS_ECDSA_PRIME256V1_PRIVATE_KEY
- ELEMENT_CALL_LIVEKIT_SECRET
- ADMIN_USER_PASSWORD