commit 708312c80c1d8e7876deb562946ccdec30309c25 Author: snoopy Date: Fri Sep 4 16:40:59 2026 +0200 Initial commit: Matrix stack (Synapse + Synapse-Admin + Element Web) behind Traefik Fully dockerized deployment with automatic Let's Encrypt certificates via Traefik's static file provider (no docker.sock needed), bind-mounted data folders instead of named volumes, and domain/secrets kept out of git via .env + rendered templates. Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f9cc6c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# ============================================================================= +# Matrix stack configuration +# Copy this file to ".env" and fill in real values before starting the stack. +# ".env" is git-ignored on purpose: it is the only place where your real +# domain name and secrets live. +# ============================================================================= + +# --- Domain ------------------------------------------------------------------ +# Bare domain used as the Matrix server_name (this is what ends up in every +# user id: @user:DOMAIN). A DNS A/AAAA record for DOMAIN and for each +# subdomain below must point to this server's IP. +DOMAIN=example.com + +# Subdomains (each needs its own DNS record pointing here) +MATRIX_SUBDOMAIN=matrix +ELEMENT_SUBDOMAIN=element +ADMIN_SUBDOMAIN=admin +TRAEFIK_SUBDOMAIN=traefik + +# --- Traefik / Let's Encrypt --------------------------------------------------- +# Email used for Let's Encrypt account registration and expiry notices. +ACME_EMAIL=changeme@example.com + +# htpasswd credentials for the Traefik dashboard (traefik.DOMAIN). +# Generate your own with: +# openssl passwd -apr1 +# Format: user:hash (escape any literal "$" as "$$" if you edit this by hand +# outside of a real .env file consumed by docker compose) +TRAEFIK_DASHBOARD_AUTH='admin:changeme' + +# --- PostgreSQL (Synapse database) ------------------------------------------- +POSTGRES_DB=synapse +POSTGRES_USER=synapse +POSTGRES_PASSWORD=changeme + +# --- Synapse ------------------------------------------------------------------- +# Shared secret used only once, to create the first admin account via +# `register_new_matrix_user`. Generate with: openssl rand -hex 32 +REGISTRATION_SHARED_SECRET=changeme diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c9e5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Real secrets / domain — never commit +.env +CREDENTIALS.txt + +# Traefik ACME storage (contains private keys + certificates) +traefik/acme/acme.json + +# Runtime data (bind-mounted host folders, not named volumes) +postgres/data/* +!postgres/data/.gitkeep + +synapse/data/* +!synapse/data/.gitkeep + +# Rendered configs (contain the real domain) — templates are tracked instead +element/config/config.json +synapse-admin/config/config.json +wellknown/html/well-known-matrix-client.json +traefik/dynamic/routes.yml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a48db50 --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Matrix stack (Synapse + Synapse-Admin + Element Web) behind Traefik + +A self-hosted Matrix homeserver, its admin panel, and a web client, all +running in Docker, fronted by Traefik with automatic Let's Encrypt +certificates. No container publishes a port to the host except Traefik +(80/443). All persistent data lives in plain host folders under this +project (no named Docker volumes), and the real domain name and secrets +live only in a local, git-ignored `.env` file. + +## Architecture + +``` +Internet ──80/443──> Traefik ──(docker network, no published ports)──┬──> Synapse (matrix.DOMAIN) + ├──> Element Web (element.DOMAIN) + ├──> Synapse-Admin (admin.DOMAIN) + ├──> nginx: /.well-known/matrix/client (DOMAIN) + └──> Traefik dashboard (traefik.DOMAIN, basic auth) + Synapse ──> PostgreSQL (internal network only) +``` + +Matrix `server_name` is the bare domain (`DOMAIN`), so user IDs look like +`@alice:DOMAIN`. The homeserver's actual HTTP API is served from +`matrix.DOMAIN`, discovered by clients via `.well-known/matrix/client` +delegation served from the bare domain. **Federation is intentionally +disabled** (private/closed server) — see `synapse/data/homeserver.yaml`. + +Traefik uses its static **file provider** for routing (not the Docker +provider / `docker.sock`), so Traefik never needs access to the Docker +socket — a smaller attack surface, and it sidesteps Docker-API-version +compatibility issues some environments have with Traefik's Docker +provider. + +## Prerequisites + +- Docker Engine + Docker Compose v2 +- A domain name with DNS **A/AAAA records pointing at this server** for: + - `DOMAIN` (e.g. `example.com`) + - `matrix.DOMAIN` + - `element.DOMAIN` + - `admin.DOMAIN` + - `traefik.DOMAIN` + (subdomain names are configurable in `.env`) + +## Setup + +1. Copy the environment template and fill in your real domain/secrets: + ```bash + cp .env.example .env + ``` + Edit `.env`: + - `DOMAIN` and the four `*_SUBDOMAIN` values + - `ACME_EMAIL` — a real address you control (used by Let's Encrypt) + - `TRAEFIK_DASHBOARD_AUTH` — generate with `openssl passwd -apr1` + - `POSTGRES_PASSWORD` — generate with `openssl rand -hex 24` + - `REGISTRATION_SHARED_SECRET` — generate with `openssl rand -hex 32` + (this one gets baked into `homeserver.yaml` in step 3 below; keep + both in sync if you change it later) + +2. Render the templated config files (Element, Synapse-Admin, the + well-known JSON, and Traefik's static routes) with your real domain: + ```bash + ./scripts/render-templates.sh + ``` + Re-run this any time you change domain-related values in `.env`. + +3. Generate the initial Synapse config (first time only): + ```bash + docker run --rm \ + -e SYNAPSE_SERVER_NAME= \ + -e SYNAPSE_REPORT_STATS=no \ + -v "$(pwd)/synapse/data:/data" \ + matrixdotorg/synapse:latest generate + ``` + Then edit `synapse/data/homeserver.yaml`: + - Remove `federation` from the listener's `resources` (already disabled + by `federation_domain_whitelist: []`, this drops the endpoint too) + - Replace the `database:` block with a `psycopg2` block pointing at + the `postgres` service, using the credentials from `.env` + - Add `public_baseurl: "https://matrix./"` + - Add `federation_domain_whitelist: []` and `enable_registration: false` + + (This repo's own deployment already has this file populated under + `synapse/data/` locally — it's git-ignored, so a fresh clone always + starts from scratch here.) + +4. Fix data directory ownership so the containers can write to their + bind-mounted folders: + ```bash + chown -R 70:70 postgres/data # postgres container's uid/gid + touch traefik/acme/acme.json && chmod 600 traefik/acme/acme.json + ``` + +5. Start everything: + ```bash + docker compose up -d + ``` + Watch `docker compose logs -f traefik` until certificates are issued. + +6. Create your first (admin) user: + ```bash + docker compose exec synapse register_new_matrix_user \ + -c /data/homeserver.yaml http://localhost:8008 -a + ``` + +## Notes + +- **Federation is off.** This is a private server for your own users only. + To enable it, remove `federation_domain_whitelist: []` (or populate it), + add `federation` back to the listener's `resources`, and expose the + federation port through a Traefik router (typically still 443 via + `.well-known/matrix/server` delegation, or a dedicated `8448` entrypoint). +- **Registration is closed.** New accounts are created via + `register_new_matrix_user` (or through Synapse-Admin once logged in as + an admin), not via public sign-up in Element. +- **Traefik dashboard** is reachable at `https://traefik.DOMAIN`, behind + HTTP basic auth (`TRAEFIK_DASHBOARD_AUTH` in `.env`). +- All data directories (`postgres/data`, `synapse/data`) are plain host + folders, not Docker named volumes — back them up like any other files. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5f81b00 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,103 @@ +networks: + web: + name: matrix_web + internal: + name: matrix_internal + +services: + traefik: + image: traefik:v3.5 + container_name: matrix-traefik + restart: unless-stopped + security_opt: + - no-new-privileges:true + networks: + - web + ports: + - "80:80" + - "443:443" + environment: + - TRAEFIK_DASHBOARD_AUTH=${TRAEFIK_DASHBOARD_AUTH} + command: + - --api.dashboard=true + - --providers.file.directory=/etc/traefik/dynamic + - --providers.file.watch=true + - --entrypoints.web.address=:80 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --entrypoints.web.http.redirections.entrypoint.permanent=true + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL} + - --certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --log.level=INFO + - --accesslog=true + volumes: + - ./traefik/dynamic:/etc/traefik/dynamic:ro + - ./traefik/acme:/etc/traefik/acme + + postgres: + image: postgres:16-alpine + container_name: matrix-postgres + restart: unless-stopped + security_opt: + - no-new-privileges:true + networks: + - internal + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - ./postgres/data:/var/lib/postgresql/data + + synapse: + image: matrixdotorg/synapse:latest + container_name: matrix-synapse + restart: unless-stopped + security_opt: + - no-new-privileges:true + depends_on: + - postgres + networks: + - web + - internal + volumes: + - ./synapse/data:/data + + element: + image: vectorim/element-web:latest + container_name: matrix-element + restart: unless-stopped + security_opt: + - no-new-privileges:true + networks: + - web + volumes: + - ./element/config/config.json:/app/config.json:ro + + synapse-admin: + image: awesometechnologies/synapse-admin:latest + container_name: matrix-synapse-admin + restart: unless-stopped + security_opt: + - no-new-privileges:true + networks: + - web + volumes: + - ./synapse-admin/config/config.json:/app/config.json:ro + + wellknown: + image: nginx:1.27-alpine + container_name: matrix-wellknown + restart: unless-stopped + security_opt: + - no-new-privileges:true + networks: + - web + volumes: + - ./wellknown/html:/usr/share/nginx/html:ro + - ./wellknown/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/element/config/config.json.template b/element/config/config.json.template new file mode 100644 index 0000000..0100123 --- /dev/null +++ b/element/config/config.json.template @@ -0,0 +1,39 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://${MATRIX_SUBDOMAIN}.${DOMAIN}", + "server_name": "${DOMAIN}" + } + }, + "default_server_name": "${DOMAIN}", + "disable_custom_urls": false, + "disable_guests": true, + "disable_login_language_selector": false, + "disable_3pid_login": false, + "force_verification": false, + "brand": "Element", + "integrations_ui_url": "", + "integrations_rest_url": "", + "integrations_widgets_urls": [], + "default_widget_container_height": 280, + "default_country_code": "FR", + "show_labs_settings": false, + "features": {}, + "default_federate": false, + "default_theme": "light", + "room_directory": { + "servers": [] + }, + "enable_presence_by_hs_url": {}, + "setting_defaults": { + "breadcrumbs": true + }, + "jitsi": { + "preferred_domain": "meet.element.io" + }, + "element_call": { + "brand": "Element Call", + "disable": false, + "use_exclusively": false + } +} diff --git a/postgres/data/.gitkeep b/postgres/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/render-templates.sh b/scripts/render-templates.sh new file mode 100755 index 0000000..0da7144 --- /dev/null +++ b/scripts/render-templates.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Renders *.template files into their real config counterparts using the +# values from .env. Run this after every edit of .env, before +# `docker compose up`. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [ ! -f .env ]; then + echo "Missing .env — copy .env.example to .env and edit it first." >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source .env +set +a + +VARS='${DOMAIN} ${MATRIX_SUBDOMAIN} ${ELEMENT_SUBDOMAIN} ${ADMIN_SUBDOMAIN} ${TRAEFIK_SUBDOMAIN}' + +envsubst "$VARS" < element/config/config.json.template > element/config/config.json +envsubst "$VARS" < synapse-admin/config/config.json.template > synapse-admin/config/config.json +envsubst "$VARS" < wellknown/well-known-matrix-client.json.template > wellknown/html/well-known-matrix-client.json +envsubst "$VARS" < traefik/dynamic/routes.yml.template > traefik/dynamic/routes.yml + +echo "Rendered config for domain: ${DOMAIN}" diff --git a/synapse-admin/config/config.json.template b/synapse-admin/config/config.json.template new file mode 100644 index 0000000..5f4164b --- /dev/null +++ b/synapse-admin/config/config.json.template @@ -0,0 +1,3 @@ +{ + "restrictBaseUrl": ["https://${MATRIX_SUBDOMAIN}.${DOMAIN}"] +} diff --git a/synapse/data/.gitkeep b/synapse/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml new file mode 100644 index 0000000..dbbbbee --- /dev/null +++ b/traefik/dynamic/middlewares.yml @@ -0,0 +1,17 @@ +http: + middlewares: + secure-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + forceSTSHeader: true + contentTypeNosniff: true + browserXssFilter: true + frameDeny: true + referrerPolicy: "strict-origin-when-cross-origin" + + dashboard-auth: + basicAuth: + users: + - '{{ env "TRAEFIK_DASHBOARD_AUTH" }}' diff --git a/traefik/dynamic/routes.yml.template b/traefik/dynamic/routes.yml.template new file mode 100644 index 0000000..a04c718 --- /dev/null +++ b/traefik/dynamic/routes.yml.template @@ -0,0 +1,73 @@ +http: + routers: + synapse: + rule: "Host(`${MATRIX_SUBDOMAIN}.${DOMAIN}`)" + entrypoints: + - websecure + service: synapse + tls: + certResolver: letsencrypt + middlewares: + - secure-headers + + element: + rule: "Host(`${ELEMENT_SUBDOMAIN}.${DOMAIN}`)" + entrypoints: + - websecure + service: element + tls: + certResolver: letsencrypt + middlewares: + - secure-headers + + synapse-admin: + rule: "Host(`${ADMIN_SUBDOMAIN}.${DOMAIN}`)" + entrypoints: + - websecure + service: synapse-admin + tls: + certResolver: letsencrypt + middlewares: + - secure-headers + + wellknown: + rule: "Host(`${DOMAIN}`)" + entrypoints: + - websecure + service: wellknown + tls: + certResolver: letsencrypt + middlewares: + - secure-headers + + dashboard: + rule: "Host(`${TRAEFIK_SUBDOMAIN}.${DOMAIN}`)" + entrypoints: + - websecure + service: api@internal + tls: + certResolver: letsencrypt + middlewares: + - dashboard-auth + - secure-headers + + services: + synapse: + loadBalancer: + servers: + - url: "http://matrix-synapse:8008" + + element: + loadBalancer: + servers: + - url: "http://matrix-element:80" + + synapse-admin: + loadBalancer: + servers: + - url: "http://matrix-synapse-admin:80" + + wellknown: + loadBalancer: + servers: + - url: "http://matrix-wellknown:80" diff --git a/wellknown/nginx.conf b/wellknown/nginx.conf new file mode 100644 index 0000000..ed0532a --- /dev/null +++ b/wellknown/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + + location = /.well-known/matrix/client { + default_type application/json; + add_header Access-Control-Allow-Origin * always; + try_files /well-known-matrix-client.json =404; + } + + location / { + return 404; + } +} diff --git a/wellknown/well-known-matrix-client.json.template b/wellknown/well-known-matrix-client.json.template new file mode 100644 index 0000000..e6b0899 --- /dev/null +++ b/wellknown/well-known-matrix-client.json.template @@ -0,0 +1,5 @@ +{ + "m.homeserver": { + "base_url": "https://${MATRIX_SUBDOMAIN}.${DOMAIN}" + } +}