Im ersten Post habe ich kurz beschrieben, welche Dienste in meinem Stack laufen. Heute geht es tiefer in den Synapse-Stack – meinen Matrix-Homeserver inklusive drei Messenger-Brücken und einem KI-Chatbot.

Warum Matrix?

Matrix ist ein offenes, föderiertes Kommunikationsprotokoll. „Föderiert" bedeutet: Jeder kann seinen eigenen Homeserver betreiben, und Nutzer verschiedener Server können miteinander kommunizieren – ähnlich wie bei E-Mail, aber für Instant Messaging.

Der entscheidende Vorteil für mich: Matrix lässt sich mit anderen Messengern brücken. Ich empfange und sende Signal-, Telegram- und WhatsApp-Nachrichten aus einem einzigen Client heraus, ohne eine dieser Apps installiert zu haben.

Überblick: Was läuft alles im Stack?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
matrix.bugtrack.eu

├── synapse               Matrix-Homeserver (matrixdotorg/synapse:v1.147.1)
   └── synapse-db        PostgreSQL 14.1

├── mautrix-signal        Signal-Brücke
   ├── signald           Signal-Protokoll-Daemon
   └── mautrix-signal-db

├── mautrix-telegram      Telegram-Brücke
   └── mautrix-telegram-db

├── mautrix-whatsapp      WhatsApp-Brücke
   └── mautrix-whatsapp-db

└── matrix-bai-bot        LLM-Chatbot (baibot)

Insgesamt laufen elf Container in diesem Stack. Jede Brücke hat eine eigene PostgreSQL-Instanz – bewusst, um Isolation zu gewährleisten und ein einzelnes Datenbankproblem nicht den gesamten Stack lahmzulegen.

Synapse: Der Homeserver

Synapse ist die Referenzimplementierung des Matrix-Protokolls in Python. Er lauscht auf zwei Ports:

  • :8008 – HTTP für Client-Anfragen und Federation, hinter Traefik
  • :8448 – HTTPS direkt (TLS) für Matrix-Federation, direkt öffentlich erreichbar

Federation auf Port 8448 muss direkt erreichbar sein, weil andere Matrix-Homeserver beim Zustellen von Nachrichten diesen Port direkt ansteuern.

Daneben exportiert Synapse auf Port 9000 Prometheus-Metriken, die vom Monitoring-Stack abgeholt werden.

App Services: Wie Brücken eingebunden werden

Jede Brücke registriert sich bei Synapse als sogenannter App Service (Anwendungsdienst). Das funktioniert über eine registration.yaml-Datei, die die Brücke generiert und die Synapse kennen muss. In der homeserver.yaml sind alle drei Registrierungen eingetragen:

1
2
3
4
app_service_config_files:
  - /data/mautrix_telegram.yaml
  - /data/mautrix_whatsapp.yaml
  - /data/mautrix_signal.yaml

Das Besondere: Diese Registrierungsdateien werden nicht manuell gepflegt. Stattdessen gibt es im Makefile ein make update-mautrix-registration-Target, das die von den Brücken generierten Dateien aus den Docker-Volumes in das Synapse-Datenverzeichnis kopiert. Nach Änderungen muss der Stack neu deployed werden.

Die Messenger-Brücken

Alle drei Brücken stammen aus dem Mautrix-Ökosystem und folgen demselben Architekturmuster: Sie verbinden sich als App Service mit Synapse und übersetzen Nachrichten bidirektional zwischen Matrix-Räumen und dem jeweiligen Messenger.

In der Praxis bedeutet das: Für jeden Signal-Kontakt, jede Telegram-Gruppe und jeden WhatsApp-Chat erstellt die Brücke automatisch einen Matrix-Raum. Nachrichten, die ich in diesem Raum schreibe, landen beim Empfänger im jeweiligen Messenger – und umgekehrt.

Signal: Die komplexeste Brücke

Signal ist die technisch anspruchsvollste Brücke, weil Signal kein offizielles API hat. Der Stack löst das mit einem zusätzlichen Dienst: signald.

1
2
3
4
5
mautrix-signal ──────── signald ──── Signal-Netzwerk
                         
                    /data/signald
                   (Geräte-Identität,
                    verschlüsselte Keys)

Signald ist ein separater Go-Daemon, der das Signal-Protokoll implementiert. Er hält den eigentlichen Gerätezustand (Schlüsselmaterial, Sitzungen) in seinem Volume. mautrix-signal kommuniziert mit signald über einen internen Socket und überlässt ihm die gesamte kryptografische Arbeit.

Der Vorteil dieser Trennung: mautrix-signal kann neu gestartet oder aktualisiert werden, ohne die Signal-Verbindung zu verlieren – der Gerätezustand liegt in signald.

Telegram und WhatsApp

Die Telegram- und WhatsApp-Brücken kommen ohne zusätzliche Daemons aus, weil beide Plattformen mit offiziellen oder zumindest dokumentierten Protokollen arbeiten.

Die Telegram-Brücke nutzt die offizielle MTProto-API (mit eigener api_id und api_hash aus einer registrierten Telegram-App). Die WhatsApp-Brücke implementiert das WhatsApp-Web-Protokoll, das technisch gesehen die Mobile-App über einen QR-Code verknüpft.

Berechtigungsmodell der Brücken

Alle drei Brücken nutzen dasselbe dreistufige Berechtigungsmodell:

Ebene Wer Rechte
relay Beliebige Matrix-User Nachrichten über die Brücke weiterleiten
user User auf matrix.bugtrack.eu Eigene Konten mit der Brücke verknüpfen
admin @karsten:matrix.bugtrack.eu Voller Zugriff inkl. Verwaltung

Baibot: Der KI-Chatbot

Baibot ist ein Matrix-Bot, der Large Language Models in Matrix-Räume einbindet. Er läuft unter dem Benutzernamen @chad:matrix.bugtrack.eu.

Der Bot unterstützt zwei Backends gleichzeitig:

  • OpenAI (GPT): Für Textgenerierung, Sprache-zu-Text (Whisper) und Text-zu-Sprache
  • OpenRouter (Gemini): Als Alternative für andere Modelle und Anbieter

Beide API-Keys werden zur Laufzeit per Docker Secret injiziert – dazu gleich mehr.

Das Besondere an baibots Sicherheitskonfiguration: Der Container läuft mit einem vollständig gesperrten Dateisystem (read_only: true) und ohne jegliche Linux-Capabilities (cap_drop: ALL). Das zwingt zu einer interessanten Lösung bei der Konfigurationsverwaltung, die ich im nächsten Abschnitt erkläre.

Konfigurationsmanagement: Templates und Secrets

Das größte Betriebsproblem bei einem Stack dieser Größe: Wie verwaltet man Konfigurationen mit sensiblen Werten so, dass sie im Git-Repository liegen können?

Das Template-Muster

Die Lösung ist einfach: Konfigurationsdateien liegen als Templates im Repository, mit Platzhaltern statt echter Werte:

1
2
3
4
5
# config/synapse-homeserver.yaml (im Repository)
database:
  args:
    password: __SYNAPSE_DB_PASS__  # Platzhalter
    host: synapse-db

Beim Containerstart liest ein kleines Entrypoint-Skript das Docker Secret und ersetzt den Platzhalter:

1
2
3
4
5
# synapse-entrypoint.sh (vereinfacht)
DB_PASS=$(cat /run/secrets/synapse_db_pass)
sed "s/__SYNAPSE_DB_PASS__/${DB_PASS}/" \
    /config-template/homeserver.yaml > /data/homeserver.yaml
exec /start.py

Die fertige Konfiguration mit den echten Werten entsteht nur im Arbeitsspeicher des Containers – sie wird nie gespeichert oder geloggt.

URL-Encoding: Ein häufig übersehenes Detail

PostgreSQL-Verbindungsstrings haben eine Besonderheit: Das Passwort muss URL-encodiert sein. Ein Passwort mit Sonderzeichen wie @ oder # bricht den Connection String ohne Encoding.

Das Entrypoint-Skript für die Brücken löst das in reinem Bash:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Prozent-Encoding für URL-sensitive Zeichen
url_encode() {
    local raw="$1"
    printf '%s' "$raw" | sed \
        -e 's/%/%25/g' \
        -e 's/ /%20/g' \
        -e 's/#/%23/g' \
        -e 's|/|%2F|g' \
        -e 's/+/%2B/g' \
        -e 's/@/%40/g'
}

Baibots Sonderfall: /dev/shm statt /tmp

Weil baibot mit read_only: true läuft, kann das Entrypoint-Skript nicht in /tmp schreiben. Die Lösung: /dev/shm – das Shared-Memory-Dateisystem, das in Linux-Containern immer als beschreibbares tmpfs verfügbar ist.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# baibot-entrypoint.sh (vereinfacht)
export BAIBOT_USER_PASSWORD=$(cat /run/secrets/synapse_baibot_user_password)
export BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY=$(cat /run/secrets/...)

# Config mit API-Keys rendern – in den Arbeitsspeicher schreiben
sed "s/__BAIBOT_OPENAI_API_KEY__/${OPENAI_KEY}/" \
    /config-template/config.yml > /dev/shm/config.yml

export BAIBOT_CONFIG_FILE_PATH=/dev/shm/config.yml
exec /app/baibot

Docker Secrets und Rotation

Alle Secrets folgen dem Namensschema <name>_YYYYMMDD – z.B. synapse_db_pass_20260101. Das ermöglicht Rotation ohne Downtime:

1
2
3
4
5
6
7
# 1. Neues Secret anlegen
echo "neues-passwort" | docker secret create synapse_db_pass_20260401 -

# 2. docker-stack.yml anpassen: externes Secret auf neue Version zeigen
# 3. Stack neu deployen – Swarm tauscht Secret live aus
# 4. Altes Secret löschen
docker secret rm synapse_db_pass_20260101

Das Makefile hat dafür fertige Targets:

1
2
make secret-rotate SUFFIX=20260401       # Alle Secrets auf neues Datum rotieren
make secret-rotate-one KEY=synapse_db_pass SUFFIX=20260401

Konfigurations-Sync: Das tägliche Werkzeug

Da Bridges und Homeserver Konfigurationsdateien in Docker-Volumes lesen, müssen Änderungen an den Templates im Repository aktiv synchronisiert werden. Das Makefile bietet dafür drei hilfreiche Targets:

1
2
3
make diff-mautrix-configuration     # Zeigt Unterschiede: Repository vs. laufende Volumes
make install-mautrix-configuration  # Kopiert neue Configs in die Volumes
make merge-mautrix-configuration    # Öffnet vimdiff für manuelles Mergen

Damit ist der Workflow bei Config-Änderungen:

  1. Template im Repository anpassen
  2. make diff-mautrix-configuration – prüfen, was sich ändert
  3. make install-mautrix-configuration – Änderungen einspielen
  4. make start – Stack neu deployen

Netzwerkarchitektur

Der Stack nutzt drei Netzwerke:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Internet
   
proxy-tier (global)
   
 Traefik ─────────── synapse (:8008)
   
monitoring (global)
   
Prometheus ─────────── synapse (:9000)

synapse-network (intern)
   ├── synapse
   ├── synapse-db
   ├── mautrix-signal ── signald
   ├── mautrix-signal-db
   ├── mautrix-telegram
   ├── mautrix-telegram-db
   ├── mautrix-whatsapp
   ├── mautrix-whatsapp-db
   └── matrix-bai-bot

Das interne synapse-network ist ein Docker-Overlay-Netzwerk. Dienste finden sich gegenseitig per Hostname (z.B. synapse-db, mautrix-signal-db). Von außen ist nichts von diesem Netzwerk erreichbar.

Fazit

Der Synapse-Stack ist mit Abstand der komplexeste Teil meiner Infrastruktur – elf Container, vier Datenbanken, drei externe Ökosysteme (Signal, Telegram, WhatsApp) und ein LLM-Backend.

Was den Betrieb handhabbar macht:

  • Konsequente Trennung von Template und Konfiguration
  • Docker Secrets für alle sensiblen Werte
  • Separate Datenbanken pro Bridge für Isolation
  • Makefile-Targets für wiederkehrende Aufgaben

Im nächsten Post schaue ich mir den Nextcloud-Stack an – der ist strukturell ähnlich komplex, aber mit ganz anderen Herausforderungen. +++