Tenant-in-a-Box + 3-Tier-Routing
Status: Architektur LIVE (2026-05-02). Alle 4 Live-Tenants (demo3, demo2, demo, leander) migriert. Backup/Restore + Auto-Scaling + Inter-Host-Migration scharf. Siehe session-recap-2026-05-02.md für Implementation-Details.
Ziel: Migration / Backup / Disaster-Recovery um Größenordnungen vereinfachen, gleichzeitig 3-Tier-Pricing (Free/Pro/Enterprise) sauber abbilden.
Was noch offen (siehe Memory project_tenant_box_open_items.md):
- Backup L2 (monthly) + L3 (yearly) mit Object-Lock
- Restore-Drill-Cron mit Sandbox-Host
- Update-Pipeline scharf (Version-Registry, Staged-Rollout)
- Enterprise-Onboarding-UI (DNS + Cert-Provisioning)
1. Status quo (warum wir das umbauen)
Heute besteht ein Shared-Tenant aus 5 unabhängigen State-Quellen:
/opt/prilog/tenants/<slug>/ compose.yml + homeserver.yaml + signing.key
/var/lib/prilog/synapse-<slug>/ media_store
shared Postgres → DB synapse_<slug>
shared MinIO → Bucket tenant-<slug>
nginx /etc/nginx/sites-enabled/<domain>.conf
+ DB.serverOrder.synapsePort, sharedHostId
+ Bunny DNS-RecordJede Migration muss alle 5 einsammeln, transferieren, drüben in der korrekten Reihenfolge wiederherstellen, und Cross-Refs (Port, DB-Creds, Bucket-Name) konsistent halten. Daraus folgen die heutigen 8 Steps und die Drift-Fallen, die wir am 2026-05-01 alle einzeln gefixt haben:
- pg_dump-Permissions
- mc-alias-Mismatch
- Postgres-Collation-Anforderung
- Compose-Port-Rewrite (sed-Pattern matched nicht 127.0.0.1-Bindings)
- Backend-Verify-Timeout
- DNS-Record-Anlage bei Subdomain-Wechsel
- Cluster-SSH-Key
- Snapshot-Inhalt (compose-Config nicht mit gepackt)
Jeder Fix ist im Code, aber die Architektur bleibt fragil: jede neue State-Quelle ist eine neue Drift-Falle.
2. Zielarchitektur
2.1 Tenant-in-a-Box (Variante A)
Pro Tenant ein Verzeichnis, das alle State-Quellen enthält.
/srv/tenants/<slug>/
├── docker-compose.yml
├── homeserver.yaml
├── signing.key
├── credentials.env (DB-Passwort, MinIO-Creds, Registration-Secret)
├── manifest.json (Schema-Version, server_name, public_baseurl, slug)
├── postgres/ (PostgreSQL Datadir, Volume-Mount)
├── minio/ (MinIO Object Storage, Volume-Mount)
└── synapse/
└── media_store/ (Synapse Media)docker-compose.yml (skizziert):
services:
postgres:
image: postgres:15-alpine
container_name: pg-${SLUG}
environment:
POSTGRES_DB: synapse
POSTGRES_USER: synapse
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
command: postgres -c shared_buffers=32MB -c max_connections=50
volumes:
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U synapse"]
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: minio-${SLUG}
environment:
MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
command: server /data
volumes:
- ./minio:/data
healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1:9000/minio/health/live"]
restart: unless-stopped
synapse:
image: matrixdotorg/synapse:latest
container_name: synapse-${SLUG}
depends_on:
postgres: { condition: service_healthy }
minio: { condition: service_healthy }
ports:
- "0.0.0.0:${SYNAPSE_PORT}:8008"
volumes:
- ./homeserver.yaml:/data/homeserver.yaml:ro
- ./signing.key:/data/signing.key:ro
- ./synapse/media_store:/data/media_store
restart: unless-stoppedKonsequenz: Migration = tar über /srv/tenants/<slug>/ + nginx-conf + DNS. Eine State-Quelle. Ein Tarball. Ein Backup-Pfad.
2.2 3-Tier-Routing
| Tier | URL (User sieht) | server_name (Matrix) | Host | DNS |
|---|---|---|---|---|
| Free | <slug>.prilog.team | <slug>.prilog.team | shared (multi-tenant) | wildcard |
| Pro | <slug>.prilog.team | <slug>.prilog.team | shared (multi-tenant) | wildcard, optional expliziter A-Record nach Migration |
| Enterprise | chat.firma.com (CNAME) | bleibt <slug>.prilog.team (siehe 2.3) | shared oder dedicated | Kunde setzt CNAME → enterprise-N.prilog.team |
Regeln für Enterprise:
- Nur Subdomains erlaubt (
chat.firma.com✅,firma.com❌ — DNS-Spec). - Kunde setzt CNAME, nicht A-Record. Damit kann Prilog jederzeit den Server verschieben ohne Kunden-DNS-Eingriff.
- TLS via Let's Encrypt HTTP-01, automatisches Renewal, Monitoring auf Cert-Validity.
2.3 Pro→Enterprise-Upgrade — der Migration-Pain ist gelöst
Schlüsselentscheidung: Der Matrix server_name ist invariant über den ganzen Lifecycle.
Bei jeder Tenant-Anlage wird server_name = <slug>.prilog.team gesetzt — egal welches Tier. User-IDs sind @alice:<slug>.prilog.team. Federation-Identity ist <slug>.prilog.team.
Pro→Enterprise-Upgrade besteht nur aus:
- Kunde setzt
chat.firma.com CNAME enterprise-N.prilog.teamin seinem DNS. - Backend prüft DNS-Resolution (
dig +short chat.firma.comzeigt auf unsere IP via CNAME-chain). - Certbot-HTTP-01 zieht Cert für
chat.firma.com. - Synapse
homeserver.yaml:server_name: <slug>.prilog.team(UNVERÄNDERT)public_baseurl: https://chat.firma.com/(NEU — User-facing URL)
- nginx-Server-Block für
chat.firma.comzeigt auf den Tenant-Container. - Optional: Move auf dedicated Host (= existing Migration-Code zwischen Shared-Hosts).
Kein server_name-Change. Keine User-ID-Migration. Keine Daten-Migration. Es ist ein nginx + Cert + (optional) Host-Move.
Cosmetic-Trade-off: User-IDs bleiben @alice:<slug>.prilog.team, nicht @alice:chat.firma.com. In der UI zeigen wir nur Display-Namen + interne Referenz, nie die rohe Matrix-ID. 99% der User merken das nie. Wer Federation-Branding mit chat.firma.com will (Edge-Case: Schule will mit anderen Matrix-Servern als chat.firma.com chatten) muss als Enterprise-from-day-one angelegt werden — dort ist server_name = chat.firma.com von Anfang an fix.
2.4 Edge-Cases
| Fall | Handling |
|---|---|
Apex-Domain Enterprise (firma.com) | nicht erlaubt — Kunde wird auf chat. / app. gelenkt |
| Federation-Branding für Pro-Customer | nicht möglich ohne Re-Setup als Enterprise-from-day-one (akzeptierter Edge-Case) |
| Kunde entfernt CNAME später | Cert-Renewal bricht → Monitoring-Alert → Kontaktaufnahme |
| Kunde will eigenes Cert mitbringen | Phase 2, nicht im MVP |
| Multi-Region-Failover Enterprise | später via GeoDNS am enterprise-N.prilog.team Endpoint, ohne Kunden-DNS-Eingriff |
3. Backup-Konzept
Leitprinzip: Backup, Migration und Disaster-Recovery sind ein einziger Code-Pfad mit drei Triggern.
Migration: snapshot → transfer → restore (Target-Host)
Backup: snapshot → upload → store (Hetzner Object Storage)
DR-Restore: download → restore (neue Tenant-Box auf gewähltem Host)
Pre-Update: snapshot → store (lokal + remote, Auto-Cleanup nach 7d)Diese Vereinheitlichung ist der zweite Hauptvorteil neben Migration-Vereinfachung: jeder Bug im Snapshot-Code wird in allen drei Pfaden gleichzeitig gefixt.
3.1 Was wird gesichert
Die Tenant-Box ist selbst-enthalten — alle relevanten State-Quellen liegen in /srv/tenants/<slug>/. Ein Snapshot ist also ein Tarball dieses Verzeichnisses + Manifest:
| Inhalt | Quelle | Größe (typ.) |
|---|---|---|
docker-compose.yml, homeserver.yaml, signing.key, credentials.env | /srv/tenants/<slug>/ (root) | <100 KB |
manifest.json (Schema-Version, Versions-Stack, Tenant-Metadata) | generiert | <2 KB |
| Postgres-Datadir | postgres/ | 100 MB – 5 GB |
| MinIO-Objekte | minio/ | 100 MB – 50 GB |
| Synapse Media-Store | synapse/media_store/ | 50 MB – 20 GB |
| Summe (Avg-Tenant) | ~5 GB | |
| Summe (Heavy-Tenant) | ~70 GB |
Was NICHT im Tarball liegt (weil außerhalb der Box):
- nginx-Server-Block (
/etc/nginx/sites-enabled/<domain>.conf) — wird beim Restore aus Manifest neu generiert - Bunny DNS-Record — wird beim Restore über Backend-API gesetzt
- Let's-Encrypt-Cert für Enterprise-Custom-Domain — wird neu beantragt (Cert-Files sind transient, kein Backup-Wert)
ServerOrder.synapsePortin der zentralen DB — wird beim Restore neu zugewiesen
Letzterer Punkt ist absichtlich: der Synapse-Port ist Host-spezifisch, beim Restore auf einem anderen Host bekommt er eine neue Zuweisung. Das verhindert Cross-Host-Port-Drift wie wir ihn bei demo3 hatten.
3.2 Snapshot-Verfahren
Standard-Pfad (MVP, Stop-Tar-Start):
SLUG=abc123
DIR=/srv/tenants/$SLUG
TS=$(date -u +%Y%m%dT%H%M%SZ)
SNAPSHOT=/var/lib/prilog/snapshots/$SLUG-$TS.tar.gz
# 1. Manifest aktualisieren (Versionen, Timestamp, Slug)
generate-manifest $SLUG > $DIR/manifest.json
# 2. Postgres clean-shutdown (FLUSH + atomic state)
docker compose -f $DIR/docker-compose.yml stop postgres
# 3. Tar mit pigz (parallel gzip) — bei 5GB ~2x schneller
tar -cf - -C $DIR . | pigz -p 4 > $SNAPSHOT.tmp
# 4. Postgres wieder starten — Tenant ist wieder schreibfähig
docker compose -f $DIR/docker-compose.yml start postgres
# 5. SHA256 + Größe in Manifest, atomic rename
sha256sum $SNAPSHOT.tmp > $SNAPSHOT.sha256
mv $SNAPSHOT.tmp $SNAPSHOTDowntime pro Tenant: 5–15s (PostgreSQL-Stop + Tar). Bei 25 Tenants/Host nightly über 90min gestaggert → kein simultaner Spike, jeder einzelne Tenant hat einen 15-Sekunden-Hiccup pro Tag.
Phase-2-Alternative (Zero-Downtime): pg_basebackup -D - -Ft -X stream parallel zum laufenden Postgres → kein Stop nötig, dafür größere Snapshot-Komplexität (WAL-Stream-Handling). Wird eingebaut wenn 15s nightly-Downtime ein Pain wird (vermutlich erst ab Tenant-Größen mit 24/7-Aktivität wie Großunternehmen).
3.3 Backup-Hierarchie + Retention
3-Tier-Backup-Strategie:
| Tier | Quelle | Frequenz | Retention | Speicherort | Restore-Zeit |
|---|---|---|---|---|---|
| L0 Pre-Update | Vor jedem Tenant-Box-Update | Event-getrieben | 7d | Lokal auf Host (/var/lib/prilog/snapshots/) | <30s (lokal) |
| L1 Daily | Nightly cron | täglich (UTC 02:00–04:00 gestaggert) | 30d | Hetzner Object Storage Frankfurt | 2–10 min |
| L2 Monthly | 1. des Monats | monatlich | 12 Monate | Hetzner Object Storage (kalt) | 5–15 min |
| L3 Yearly Archive | 1. Januar | jährlich | 7 Jahre (DSGVO-Compliance) | Hetzner Object Storage (kalt, Glacier-äquivalent) | 30 min |
Begründung der Retention-Wahl:
- 30d daily: typisches Recovery-Fenster für "Datenpanne, die User erst nach Tagen meldet"
- 12 monatlich: für Audits + langfristige "wir haben am 15. März XY gelöscht" Anfragen
- 7 Jahre: Bildungsbereich + DSGVO-Aufbewahrungsfristen für Schul-Dokumente
3.4 Speicherort + Verschlüsselung
Primär: Hetzner Object Storage Frankfurt (S3-kompatibel, EU-only, DSGVO-konform).
- ~3 EUR/TB/Monat (deutlich günstiger als AWS S3).
- Bucket pro Stage:
prilog-backups-prod,prilog-backups-staging. - Pfad-Schema:
<bucket>/tenants/<slug>/<tier>/<timestamp>.tar.gz(z.B.tenants/leander/daily/2026-05-01T020000Z.tar.gz).
Verschlüsselung at-rest:
- Tarball wird vor Upload mit AES-256-GCM verschlüsselt (
age-Tool oder OpenSSL). - Master-Key in Vault/HashiCorp/SOPS im Backend (NICHT auf Shared-Hosts — sonst hat ein kompromittierter Host Zugriff auf alle Backups).
- Schema: pro-Tenant-Encryption-Key, derived from Master-Key + Tenant-ID (HKDF). Damit ist auch im Backup-Storage Tenant-Trennung gewahrt.
- Hetzner Object Storage hat zusätzlich serverseitige Verschlüsselung (Defense-in-Depth).
Verschlüsselung in-transit:
- Upload via S3-Protokoll über TLS.
- Restore-Download identisch.
Geographische Redundanz (Phase 2):
- MVP: einzelne Region (Frankfurt). Reicht für DSGVO + 99.9% Disaster-Szenarien.
- Phase 2: Cross-Region-Replikation Frankfurt → Helsinki für L2/L3-Backups (monatlich + jährlich). +50% Storage-Kosten, dafür Schutz gegen Region-Total-Ausfall.
3.5 Integrität + Verifikation
Beim Snapshot:
- SHA256 jedes Tarballs → in Manifest gespeichert
- Tar-Format mit
--checkpoint→ laufende Größenkontrolle
Beim Upload:
- Server-side Checksum (
x-amz-content-sha256) → Hetzner verifiziert Bit-Identität - Backend prüft Hetzner-Response gegen lokales SHA256
Bei Restore:
- SHA256 erneut prüfen vor Entpacken
- Manifest-Schema-Version prüfen → Restore-Logik weiß, welches Format zu erwarten ist
- Post-Restore: Pre-Verify (Synapse-
/_matrix/client/versionsantwortet) BEVOR DNS umgebogen wird
Restore-Drills (proactive Verifikation):
- Wöchentlich zieht ein Cron-Job einen zufälligen Tenant-Snapshot und restored ihn auf einen Sandbox-Host.
- Verify-Schritt prüft: Synapse startet, DB ist konsistent, MinIO-Bucket ist mountable.
- Bei Failure → Alert. Damit fangen wir Backup-Korruption innerhalb von 7d, nicht erst beim echten DR-Vorfall.
- Der Sandbox-Host wird nach dem Test wieder weggeworfen (1 Hetzner-Server, ein paar EUR/Monat).
3.6 Restore-Verfahren
Pfad A: Single-Tenant-Restore (häufigster Fall — Tenant hat versehentlich Daten gelöscht):
1. Admin wählt im Tenant-Board den Snapshot-Zeitpunkt
2. Backend lädt Tarball aus Object Storage → entschlüsselt → SHA256-Verify
3. Restore-Modus wählen:
a) "In neue Box wiederherstellen" — Snapshot kommt unter <slug>-restored-<ts>/, alter Tenant bleibt live, Admin kann manuell mergen
b) "Aktuelle Box ersetzen" — Tenant downtime ~2min, vorher automatischer Pre-Restore-Snapshot des aktuellen Zustands (Sicherheitsnetz)
4. Tenant-Box-Lifecycle: stop → tar -xzf → start → verify
5. Audit-Log-Eintrag in DBPfad B: Host-komplett-Verlust (Worst Case — Hetzner-Host explodiert):
1. Backend identifiziert alle betroffenen Tenants (sharedHostId == verlorener Host)
2. Backend provisioniert neuen Hetzner-Host (Auto-Provision-Code-Pfad)
3. Pro Tenant: letzten L1-Snapshot aus Object Storage → restore auf neuen Host
4. nginx-Configs werden aus Manifest neu generiert
5. DNS-Update via Bunny-API (zeigt jetzt auf neuen Host)
6. Verify pro TenantWorst-Case-RTO bei 25-Tenant-Host-Verlust: ~60 Minuten (5 min Server-Provision + 2 min Restore × 25 Tenants seriell, oder ~10 min parallel mit 5er-Pool).
RPO (max. Datenverlust): bis zu 24h (letzter Daily-Snapshot). Für Pro+Enterprise-Tenants kann zusätzlich WAL-Streaming aktiviert werden → RPO ~5min. MVP nicht inklusive.
3.7 RTO + RPO Targets pro Tier
| Tier | RTO (Recovery Time) | RPO (Datenverlust) | Backup-Frequenz |
|---|---|---|---|
| Free | <4h | <24h | nightly |
| Pro | <1h | <24h | nightly |
| Enterprise | <30min | <1h (WAL-Stream optional) | nightly + WAL-Streaming |
Eskalation bei Enterprise: WAL-Streaming auf Object-Storage gibt RPO~5min, benötigt aber kontinuierlichen WAL-Stream → +2 EUR/Monat/Tenant Storage-Kosten. Wird Enterprise-Add-on.
3.8 Disaster-Szenarien (was wir schützen)
| Szenario | Geschützt durch | Recovery-Pfad |
|---|---|---|
| Versehentlicher User-Datenlöschung (Mitarbeiter "oh nein") | L1 Daily | Single-Tenant-Restore (3.6 Pfad A) |
| Kompromittierter Tenant durch Schadsoftware | L1 Daily, ggf. L0 vor Update | Restore aus prä-Compromise-Backup |
| Postgres-Korruption (selten, aber möglich) | L0 Pre-Update + L1 Daily | Restore aus letztem konsistenten Snapshot |
| Synapse-Schema-Bug nach Update | L0 Pre-Update | Sofortiges Rollback (auf Host, ohne Object-Storage-Download) |
| Host-Hardware-Ausfall | L1 Daily | Host-komplett-Restore (3.6 Pfad B) |
| Hetzner-Region-Ausfall | L2 Monthly Cross-Region (Phase 2) | Restore aus Helsinki-Backup |
| Komplette Hetzner-Account-Suspendierung | externe Replikation (Phase 3, optional) | Restore beim Drittanbieter-Storage |
| Ransomware auf Backend (alle Backups verschlüsselt) | Object-Storage mit Object-Lock (write-once-read-many) für L2+L3 | Restore aus immutable Tier |
| Versehentlicher Master-Key-Verlust | Key-Escrow in 2 separaten Locations (Yubikey-Backup + Vault) | Key-Recovery-Prozedur |
Wichtig: Object-Lock auf L2/L3 ist die Antwort auf Ransomware. Selbst wenn Angreifer Backend-Credentials hat, kann er die monatlichen+jährlichen Backups nicht löschen oder überschreiben.
3.9 Monitoring + Alerting
Was monitort wird:
- Backup-Job-Status pro Tenant pro Tier (last_success_at, duration_ms, size_bytes, sha256_match)
- Backup-Age: Alert wenn ein Tenant >36h kein L1 hat (Daily-Backup verpasst)
- Storage-Usage: Alert wenn Object-Storage-Bucket >80% Quota
- Restore-Drill-Ergebnisse: Alert bei Fail
- Cert-Expiry Backup-Pipeline (S3-Endpoint-TLS, Encryption-Keys)
Alerts:
- Slack/Email an
ops@prilog.teambei Daily-Failure - Pager an On-Call bei kompletten Tier-Failures (z.B. alle Daily-Backups schlagen fehl → Object-Storage-Outage)
Tenant-Board-UI:
- Pro Tenant: "Letzter Snapshot: vor 4h ✅" / "Letzter Snapshot: vor 38h ⚠️"
- Pro Tenant: "Restore"-Button mit Snapshot-Selector (öffnet Drilldown)
3.10 Kosten-Kalkulation
Storage-Bedarf pro Shared-Host (25 Tenants × 5 GB Avg):
| Tier | Snapshots | Storage | Monatlich |
|---|---|---|---|
| L1 Daily × 30 | 750 × 5 GB | 3.75 TB | 11 EUR |
| L2 Monthly × 12 | 300 × 5 GB | 1.5 TB | 4.50 EUR |
| L3 Yearly × 7 | 175 × 5 GB | 0.9 TB | 2.70 EUR |
| Summe | ~6 TB | ~18 EUR |
Backend-Compute (Cron-Jobs, Restore-Drills): vernachlässigbar (<1 EUR/Monat auf api.prilog.chat).
Skalierung: lineare 18 EUR/Monat pro Shared-Host. Bei 15 Shared-Hosts: 270 EUR/Monat — gegen vermiedene Disaster-Schäden ein Schnäppchen.
Pro Enterprise-Dedicated-Host: durchschnittlich 1 Tenant × ~20 GB (typisch größer) → 4× 5GB-Tenant-Volumen → ~3 EUR/Monat zusätzlich pro Enterprise-Host.
3.11 DSGVO + Compliance
- Right-to-be-forgotten: wenn ein User/Tenant löscht, müssen wir auch alle seine Backups löschen. Implementation: Tenant-Deletion-Job iteriert alle Backup-Tiers → löscht alle Tarballs mit
tenants/<slug>/Prefix. Object-Lock auf L2/L3 muss vorher gelöst werden (Compliance-Override durch dokumentierten Prozess). - Audit-Log: jeder Restore + jede Backup-Löschung wird in
tenant_backup_auditTabelle protokolliert (wer, was, wann, warum). - Aufbewahrungsfristen: L3 Yearly = 7 Jahre für Schul-Dokumente. Konfigurierbar pro Tenant (z.B. Privatschulen können 10 Jahre wählen → höherer Storage-Tarif).
- Schlüssel-Verwaltung: Encryption-Keys werden in einem auditierten KMS gespeichert (HashiCorp Vault oder einfacher: SOPS + git-encrypted file im prilog-infra repo mit YubiKey-Schutz).
3.12 Migration der bestehenden Backup-Infrastruktur
Heute existiert kein systematisches Backup. Im Onboarding-Pfad werden:
- Phase 5 startet mit retroaktivem L1-Daily-Backup ALLER bestehenden Tenants ab Tag 1.
- L0-Pre-Update wird sofort aktiv mit dem ersten Tenant-Box-Rollout.
- L2/L3 erst aktiv wenn das System ~3 Monate stabil läuft (sonst sind monthly/yearly Backups noch leer).
Backfill-Plan: Beim Migrationsschritt der 4 bestehenden Tenants (siehe Sektion 6) wird unmittelbar nach erfolgreichem Migration-Verify das erste Daily-Backup gezogen und in Object Storage hochgeladen. Damit haben wir ab Tag 0 der neuen Architektur konsistente Backups.
4. Update-Strategie & Version-Management
Leitprinzip: Niemand SSH'd händisch auf einen Host. Alle Updates laufen über Backend → Agent → Tenant-Box. Versionen sind explizit gepinnt, kein :latest.
4.1 Update-Layer
| Layer | Komponenten | Frequenz | Mechanismus | Downtime |
|---|---|---|---|---|
| L0 Backend | prilog-backend-api, admin, web-client | täglich/per Push | GitHub Actions → pm2 restart auf api.prilog.chat | <30s |
| L1 Agent | prilog-agent auf Shared-Hosts | wöchentlich, staged | Backend → WS-Command → Agent self-update | 0s (WS-Reconnect) |
| L2 Tenant-Box | Synapse, Postgres-Minor, MinIO | monatlich, staged | Backend → Agent → docker compose pull && up -d pro Box | 30–120s pro Box |
| L3 Host-OS | Ubuntu, Docker, nginx, Tailscale, certbot | vierteljährlich | unattended-upgrades + manuelle Verification + reboot | 2–5min Host-Reboot, gestaggert |
| L4 Postgres-Major | 15→16, 16→17 (alle 1–2 Jahre) | seltene Events | pg_upgrade in dual-volume Setup, pro Tenant | 5–10min pro Tenant |
4.2 Version-Registry
Single Source of Truth: zentrale version-registry.json im Backend (DB-Tabelle oder Git-File):
{
"current": {
"synapse": "1.95.0",
"postgres": "15.5-alpine",
"minio": "RELEASE.2024-12-13",
"agent": "0.8.2"
},
"canary": {
"synapse": "1.96.0"
},
"rollout_state": {
"synapse_1.96.0": { "phase": "canary", "started_at": "2026-05-15T02:00Z", "tenants": ["auto-test-30b"] }
}
}Pro Tenant-Box: manifest.json dokumentiert die exakt installierten Versionen:
{
"schema_version": 1,
"tenant_slug": "abc123",
"stack": {
"synapse": "1.95.0",
"postgres": "15.5-alpine",
"minio": "RELEASE.2024-12-13"
},
"last_updated": "2026-05-01T03:14:00Z",
"last_pre_update_snapshot": "abc123-20260501T031200Z.tar.gz"
}Drift-Detection: Cron-Job prüft hourly, ob jede Tenant-Box die Versionen aus Registry-current hat. Drift → Alert.
4.3 Staged-Rollout-Pipeline (für L2 Tenant-Box-Updates)
Phase 1: 1 internal Test-Tenant (auto-test-30b) → 1h soak
Phase 2: 5% der Free-Tenants (Canary, randomisiert) → 24h soak
Phase 3: 100% Free, 0% Pro/Enterprise → 48h soak
Phase 4: 100% Pro → 24h soak
Phase 5: 100% Enterprise (jeweils im konfigurierten Maintenance-Window)Jede Phase prüft: Synapse-Health-Endpoint, Postgres-Connection, MinIO-Health, keine erhöhten 5xx-Logs in den letzten 60min. Bei Anomalie → automatisch Halt, Alert an Ops.
Pro Tenant:
- Pre-Update-Snapshot ziehen (L0, lokal auf Host für 7d)
docker compose pull(lädt neue Images, läuft parallel zum alten Container)docker compose up -d(rolling: depends_on healthcheck → graceful restart)- Post-Update-Health-Check (5min Soft-Check, dann gut)
- Bei Failure → automatisch Restore aus L0-Snapshot, Tenant zurück auf alte Version
4.4 Sicherheits-CVEs (Hotpath)
Außerhalb des regulären Rollout-Fensters bei kritischen CVEs:
- Backend hat einen Emergency-Rollout-Modus (überspringt Staging, gleichzeitig auf alle Hosts/Tenants)
- Pre-Update-Snapshot bleibt verpflichtend (gleicher Code-Pfad)
- Audit-Log mit "emergency reason" Pflichtfeld
- Slack-Alert bevor + nach dem Rollout
4.5 Synapse Schema-Migrations
Synapse migriert seine DB beim ersten Boot nach Version-Bump → kann 30–180s dauern bei großen Tenants. Update-Pipeline:
verify-Phase wartet bis zu 180s (wie bereits implementiert)- Während Schema-Migration: nginx returnt 503 statt Connection-Error → Frontend zeigt "Update in Arbeit, Moment bitte"
4.6 Postgres-Major-Upgrade-Strategie (L4)
Seltenes Event (alle 1–2 Jahre), aber riskant. Strategie:
1. Pre-Update-Snapshot (Pflicht)
2. Tenant freezen (5–10min Wartung an User kommunizieren)
3. compose stop postgres
4. Neues Postgres-Volume anlegen (postgres-new/)
5. pg_upgrade --old-datadir postgres/ --new-datadir postgres-new/ --link
(--link spart 99% I/O, dafür ist Rollback nicht trivial)
6. compose up -d (mit neuem Volume)
7. ANALYZE; verify
8. postgres/ → postgres-old/ umbenennen, 7d aufheben für Rollback
9. Tenant unfreezenWird einmalig getestet auf einem Sandbox-Tenant, dann produktiv durchgezogen — manuelle Auslösung pro Tenant, kein Auto-Rollout für Major-Upgrades.
4.7 Was wir damit gewinnen ggü. heute
Heute (shared-Postgres-Architektur):
- Postgres-Update = ALLE Tenants des Hosts gleichzeitig down
- Synapse-Update = ALLE Tenants des Hosts gleichzeitig down (gemeinsamer Postgres-Schema)
- Kein Rollback-Pfad
- Manuelles SSH + Logfile-Lesen
Mit Tenant-in-a-Box:
- Updates Tenant-für-Tenant, mit Canary, mit Rollback
- Pre-Update-Snapshot als Sicherheitsnetz, identisch mit Backup-Pipeline
- Komplette Steuerung über Tenant-Board-UI, kein SSH
- Audit-Trail für Compliance
5. Performance + Sizing
| Komponente | RAM/Tenant | Tuning |
|---|---|---|
| Synapse | ~250 MB | unverändert |
| Postgres | ~150 MB | shared_buffers=32MB (default 128MB) |
| MinIO | ~50 MB | MINIO_CACHE=off |
| Summe | ~450 MB |
| Hetzner-Typ | RAM | Praktische Tenants/Host |
|---|---|---|
| CCX13 | 8 GB | ~12 (eng) |
| CCX23 | 16 GB | ~28 (Standard) |
| CCX33 | 32 GB | ~60 |
| CCX43 | 64 GB | ~120 |
Empfehlung: CCX23 als neuer Default für Shared-Hosts. Heutige 4 Tenants laufen darauf locker, Headroom für 24 weitere ohne Server-Move.
Performance pro Tenant: unverändert oder besser.
- Synapse → Postgres läuft im selben docker-bridge-Network → niedrigere Latenz als shared-Postgres-Crossing-the-Network
- I/O-Isolation: ein Tenant mit Vacuum/Reindex blockiert keine anderen mehr
- Crash-Containment: depends_on + healthcheck ⇒ docker compose restartet Tenant-Box atomar
6. Migration der 4 bestehenden Tenants (demo, demo2, demo3, leander)
Strategie: One-Time-Migration vom alten Layout (shared Postgres, shared MinIO, separate Pfade) zum neuen Tenant-in-a-Box-Layout.
Pro Tenant-Migration:
1. Tenant freezen (TenantSetting.maintenance=true → blockt Schreibzugriffe)
2. /srv/tenants/<slug>/ anlegen mit neuem docker-compose.yml + secrets
3. Postgres-Container starten (leer, mit C-Collation)
4. pg_dump aus shared Postgres → pg_restore in pro-Tenant Postgres
5. MinIO-Container starten
6. mc mirror aus shared MinIO Bucket → in pro-Tenant MinIO
7. Synapse media_store: tar von /var/lib/prilog/synapse-<slug>/ → /srv/tenants/<slug>/synapse/media_store/
8. homeserver.yaml umschreiben: DB-Connection auf pg-<slug>:5432, MinIO auf minio-<slug>:9000
9. Synapse-Container starten
10. Pre-Verify (curl /_matrix/client/versions)
11. nginx-conf umbiegen: proxy_pass auf neuen Synapse-Port
12. nginx reload
13. Post-Verify (curl https://<slug>.prilog.team/_matrix/client/versions)
14. Tenant unfreezen
15. Old-stack down (alter shared-Synapse-Container, alter media_store unter /var/lib/prilog/)
16. Nach 7 Tagen: alte DB im shared Postgres droppen, alten Bucket im shared MinIO löschenGeschätzte Downtime pro Tenant: 30–60 Sekunden (von Step 11 nginx reload bis Step 13 verify-OK).
Reihenfolge der 4 Tenants:
- demo3 zuerst (Test-Tenant, kein realer Schaden falls Probleme)
- demo2 (Test-Tenant)
- demo (Test-Tenant)
- leander zuletzt (echter Kunde)
7. Sequenzplan (Phasen)
| Phase | Inhalt | Aufwand |
|---|---|---|
| 0 | Konzept-Review + Approval | 1 Tag |
| 1 | docker-compose-Template + manifest.json-Schema + lokal validieren | 2 Tage |
| 2 | Auf shared-2 neue Tenant-Box auto-test-30b anlegen, end-to-end testen | 1 Tag |
| 3 | One-Time-Migration-Script (alter→neuer Layout) entwickeln + auf demo3 testen | 2 Tage |
| 4 | demo3 + demo2 + demo + leander migrieren | 1 Tag |
| 5a | Backup-Pipeline L0/L1 (Pre-Update + Daily) zu Hetzner Object Storage | 2 Tage |
| 5b | Backup-Pipeline L2/L3 (Monthly + Yearly Archive, Object-Lock) | 1 Tag |
| 5c | Restore-Drill-Cron + Tenant-Board-UI für Restore | 2 Tage |
| 6 | Update-Pipeline + Version-Registry + Staged-Rollout-Logik | 3 Tage |
| 7 | 3-Tier-Pricing im Backend + Stripe-Hookup | 2 Tage |
| 8 | Enterprise-Onboarding-UI (DNS-Anweisung, "Test DNS"-Button, Cert-Provisioning) | 3 Tage |
| 9 | Deprovision shared Postgres + shared MinIO (nach 7d Soak) | 1 Tag |
Gesamt: ~21 Arbeitstage, davon Phase 1–4 kritischer Pfad (~6 Tage).
Phase 5–9 können parallel laufen, sobald Phase 4 stabil ist. Phase 5a ist Pflicht-Voraussetzung für Phase 6 (Update-Pipeline braucht Pre-Update-Snapshots).
8. Risiken + Open Issues
| Risiko | Mitigation |
|---|---|
| Postgres-Memory-Spike bei 25 Tenants | shared_buffers=32MB hardcoded im Template, max_connections=50 |
Backup-Konsistenz mit naivem tar über laufendes Postgres | Stop-Tar-Start (10s Downtime), Phase 2 → pg_basebackup |
| Backup-Encryption-Key-Verlust = Total-Datenverlust | Master-Key in 2 separaten Locations (Vault + YubiKey-Backup), dokumentierte Recovery-Prozedur, Restore-Drill prüft auch Key-Erreichbarkeit |
| Ransomware verschlüsselt alle Backups | Object-Lock auf L2/L3 (write-once-read-many, immutable), Compliance-Override-Prozess für legale Löschung |
| Cert-Renewal bei Enterprise-CNAME-Änderung durch Kunde | Monitoring auf Cert-Validity + Alert bei <14 Tagen |
| One-Time-Migration der 4 Tenants verläuft nicht reibungslos | Rollback-Script: alte Stacks bleiben 7 Tage warm-stehen, schneller Switch zurück möglich |
| Synapse-Federation bei Pro-Tenants weiter aktiv | Default federation_domain_whitelist: [] für Free + Pro deaktivieren |
| Disk-IO bei 25 parallel laufenden Postgres-Instanzen mit WAL-Sync | NVMe-only Hetzner-Plattform, kein Issue |
| MinIO 1-Disk-Setup: keine Erasure-Coding-Redundanz innerhalb Tenant-Box | Akzeptiert — Backup-Strategie kompensiert. Single-Disk-MinIO ist offiziell supported für Single-Drive Mode |
| Update bricht in Tenant-Box, Rollback-Snapshot ist auch korrupt | Wöchentliches Restore-Drill prüft Snapshot-Integrität proaktiv. L0+L1 sind redundant (lokal + remote) |
| Hetzner Object Storage temporär nicht erreichbar während Backup-Job | L0 Pre-Update lokal weiter funktioniert. L1 Daily wird mit exponential backoff retried, dann skipped+alert |
Drift zwischen version-registry.json (Soll) und Tenant-Box-manifest.json (Ist) | Hourly-Cron prüft + alert. Drift-Auto-Repair optional in Phase 2 |
| Postgres-Major-Upgrade scheitert bei einem Tenant mit Custom-Extensions | Vor Major-Upgrade Sandbox-Test pro Tenant. Bei Fail: pg_upgrade rollback via --link + altes Volume zurück |
9. Was sich für den Code ändert
Neue Komponenten:
prilog-agent/src/handlers/tenant-box.ts— Lifecycle-Operationen (create, snapshot, restore, destroy, update)prilog-agent/templates/tenant-box.docker-compose.yml.tplprilog-backend-api/src/services/tenant-box.service.ts— Backend-seitige Orchestrierungprilog-backend-api/src/services/cert.service.ts— Let's-Encrypt-HTTP-01 für Enterprise-Custom-Domainsprilog-backend-api/src/services/backup.service.ts— Snapshot-Encryption, Object-Storage-Upload, Manifest-Trackingprilog-backend-api/src/services/version-registry.service.ts— Version-Pinning, Rollout-State, Drift-Detectionprilog-backend-api/src/cron/backup.cron.ts— nightly snapshots (L1), monthly archives (L2), yearly archives (L3)prilog-backend-api/src/cron/restore-drill.cron.ts— wöchentliches Restore-Drill auf Sandbox-Hostprilog-backend-api/src/cron/version-drift.cron.ts— hourly Drift-Detection- DB-Tabellen:
tenant_backup,tenant_backup_audit,tenant_box_version,version_rollout_state
Wegfallende Komponenten:
shared PostgresProvisioning (alle DBs werden migriert, Postgres-Service auf Shared-Host kann gestoppt werden)shared MinIOProvisioning (analog)- Port-Rewrite-Logik in
migration.ts(Tenant-Box hat eigenen internen Port, Host-Port wird beim Start des compose-Stacks frisch zugewiesen) - Komplexe pg_dump-Snapshot-Logik (wird durch Volume-Tar ersetzt)
Breaking Changes:
- Bestehende
provision-shared.tsCode-Pfad bleibt während Übergang als Read-Only-Fallback. Neue Tenants ab Phase 4 nutzen Tenant-Box-Pfad. - Backend-
getServerForTenant-Resolver: schaltet Tenants nach Migration auf neue Connection-Daten um.
10. Memory + Memorabilia
Diese Architektur löst — bei voller Umsetzung — folgende vergangene Incidents permanent:
- demo3 DB-vs-Runtime-Drift (manuelles Recovery): Tenant-Box hat keinen externen Port-Cross-Reference; alles im selben compose-Stack.
- leander Migration-Rollback: Pre-Cutover-Verify ist implementiert; Tenant-Box-Tarball ist atomar restorierbar.
- demo Port-Konflikt (sed-Pattern): Tenant-Boxen kapseln ihre Ports im docker-network, Host-Port wird vom compose-Stack vergeben.
- mc-alias-Drift, pg_dump-Permissions, Postgres-Collation-Falle: alle State liegt in Volumes pro Tenant, keine cross-Tenant-Permissions-Themen mehr.
11. Was wir nicht machen
- Kein zentrales Login auf
prilog.teammit Path-Routing (prilog.team/<slug>). Synapse-hinter-Path-Prefix ist ein "less-traveled-path" mit unbekannten Bugs; der Gewinn (1 DNS-API-Call weniger) rechtfertigt die Risiken nicht. - Kein Single-Tenant-Synapse mit Soft-Tenancy. DSGVO-Risiko, Skalierungs-Cap.
- Kein ZFS/btrfs für Snapshot-Replikation. Zu spezielles Ops-Wissen, Linux-native Lösung mit Hetzner Object Storage reicht.
- Kein server_name-Change bei Pro→Enterprise-Upgrade. Server-Name ist invariant über den Lifecycle (siehe 2.3).
- Keine Apex-Domain für Enterprise. Nur Subdomains (Industry-Standard).
Nächster Schritt: Approval einholen, dann Phase 1 starten (docker-compose-Template).