Skip to content

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-Record

Jede 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):

yaml
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-stopped

Konsequenz: Migration = tar über /srv/tenants/<slug>/ + nginx-conf + DNS. Eine State-Quelle. Ein Tarball. Ein Backup-Pfad.

2.2 3-Tier-Routing

TierURL (User sieht)server_name (Matrix)HostDNS
Free<slug>.prilog.team<slug>.prilog.teamshared (multi-tenant)wildcard
Pro<slug>.prilog.team<slug>.prilog.teamshared (multi-tenant)wildcard, optional expliziter A-Record nach Migration
Enterprisechat.firma.com (CNAME)bleibt <slug>.prilog.team (siehe 2.3)shared oder dedicatedKunde 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:

  1. Kunde setzt chat.firma.com CNAME enterprise-N.prilog.team in seinem DNS.
  2. Backend prüft DNS-Resolution (dig +short chat.firma.com zeigt auf unsere IP via CNAME-chain).
  3. Certbot-HTTP-01 zieht Cert für chat.firma.com.
  4. Synapse homeserver.yaml:
    • server_name: <slug>.prilog.team (UNVERÄNDERT)
    • public_baseurl: https://chat.firma.com/ (NEU — User-facing URL)
  5. nginx-Server-Block für chat.firma.com zeigt auf den Tenant-Container.
  6. 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

FallHandling
Apex-Domain Enterprise (firma.com)nicht erlaubt — Kunde wird auf chat. / app. gelenkt
Federation-Branding für Pro-Customernicht möglich ohne Re-Setup als Enterprise-from-day-one (akzeptierter Edge-Case)
Kunde entfernt CNAME späterCert-Renewal bricht → Monitoring-Alert → Kontaktaufnahme
Kunde will eigenes Cert mitbringenPhase 2, nicht im MVP
Multi-Region-Failover Enterprisespä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:

InhaltQuelleGröß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-Datadirpostgres/100 MB – 5 GB
MinIO-Objekteminio/100 MB – 50 GB
Synapse Media-Storesynapse/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.synapsePort in 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):

bash
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 $SNAPSHOT

Downtime 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:

TierQuelleFrequenzRetentionSpeicherortRestore-Zeit
L0 Pre-UpdateVor jedem Tenant-Box-UpdateEvent-getrieben7dLokal auf Host (/var/lib/prilog/snapshots/)<30s (lokal)
L1 DailyNightly crontäglich (UTC 02:00–04:00 gestaggert)30dHetzner Object Storage Frankfurt2–10 min
L2 Monthly1. des Monatsmonatlich12 MonateHetzner Object Storage (kalt)5–15 min
L3 Yearly Archive1. Januarjährlich7 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/versions antwortet) 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 DB

Pfad 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 Tenant

Worst-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

TierRTO (Recovery Time)RPO (Datenverlust)Backup-Frequenz
Free<4h<24hnightly
Pro<1h<24hnightly
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)

SzenarioGeschützt durchRecovery-Pfad
Versehentlicher User-Datenlöschung (Mitarbeiter "oh nein")L1 DailySingle-Tenant-Restore (3.6 Pfad A)
Kompromittierter Tenant durch SchadsoftwareL1 Daily, ggf. L0 vor UpdateRestore aus prä-Compromise-Backup
Postgres-Korruption (selten, aber möglich)L0 Pre-Update + L1 DailyRestore aus letztem konsistenten Snapshot
Synapse-Schema-Bug nach UpdateL0 Pre-UpdateSofortiges Rollback (auf Host, ohne Object-Storage-Download)
Host-Hardware-AusfallL1 DailyHost-komplett-Restore (3.6 Pfad B)
Hetzner-Region-AusfallL2 Monthly Cross-Region (Phase 2)Restore aus Helsinki-Backup
Komplette Hetzner-Account-Suspendierungexterne 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+L3Restore aus immutable Tier
Versehentlicher Master-Key-VerlustKey-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.team bei 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):

TierSnapshotsStorageMonatlich
L1 Daily × 30750 × 5 GB3.75 TB11 EUR
L2 Monthly × 12300 × 5 GB1.5 TB4.50 EUR
L3 Yearly × 7175 × 5 GB0.9 TB2.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_audit Tabelle 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:

  1. Phase 5 startet mit retroaktivem L1-Daily-Backup ALLER bestehenden Tenants ab Tag 1.
  2. L0-Pre-Update wird sofort aktiv mit dem ersten Tenant-Box-Rollout.
  3. 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

LayerKomponentenFrequenzMechanismusDowntime
L0 Backendprilog-backend-api, admin, web-clienttäglich/per PushGitHub Actions → pm2 restart auf api.prilog.chat<30s
L1 Agentprilog-agent auf Shared-Hostswöchentlich, stagedBackend → WS-Command → Agent self-update0s (WS-Reconnect)
L2 Tenant-BoxSynapse, Postgres-Minor, MinIOmonatlich, stagedBackend → Agent → docker compose pull && up -d pro Box30–120s pro Box
L3 Host-OSUbuntu, Docker, nginx, Tailscale, certbotvierteljährlichunattended-upgrades + manuelle Verification + reboot2–5min Host-Reboot, gestaggert
L4 Postgres-Major15→16, 16→17 (alle 1–2 Jahre)seltene Eventspg_upgrade in dual-volume Setup, pro Tenant5–10min pro Tenant

4.2 Version-Registry

Single Source of Truth: zentrale version-registry.json im Backend (DB-Tabelle oder Git-File):

json
{
  "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:

json
{
  "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:

  1. Pre-Update-Snapshot ziehen (L0, lokal auf Host für 7d)
  2. docker compose pull (lädt neue Images, läuft parallel zum alten Container)
  3. docker compose up -d (rolling: depends_on healthcheck → graceful restart)
  4. Post-Update-Health-Check (5min Soft-Check, dann gut)
  5. 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 unfreezen

Wird 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

KomponenteRAM/TenantTuning
Synapse~250 MBunverändert
Postgres~150 MBshared_buffers=32MB (default 128MB)
MinIO~50 MBMINIO_CACHE=off
Summe~450 MB
Hetzner-TypRAMPraktische Tenants/Host
CCX138 GB~12 (eng)
CCX2316 GB~28 (Standard)
CCX3332 GB~60
CCX4364 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öschen

Geschätzte Downtime pro Tenant: 30–60 Sekunden (von Step 11 nginx reload bis Step 13 verify-OK).

Reihenfolge der 4 Tenants:

  1. demo3 zuerst (Test-Tenant, kein realer Schaden falls Probleme)
  2. demo2 (Test-Tenant)
  3. demo (Test-Tenant)
  4. leander zuletzt (echter Kunde)

7. Sequenzplan (Phasen)

PhaseInhaltAufwand
0Konzept-Review + Approval1 Tag
1docker-compose-Template + manifest.json-Schema + lokal validieren2 Tage
2Auf shared-2 neue Tenant-Box auto-test-30b anlegen, end-to-end testen1 Tag
3One-Time-Migration-Script (alter→neuer Layout) entwickeln + auf demo3 testen2 Tage
4demo3 + demo2 + demo + leander migrieren1 Tag
5aBackup-Pipeline L0/L1 (Pre-Update + Daily) zu Hetzner Object Storage2 Tage
5bBackup-Pipeline L2/L3 (Monthly + Yearly Archive, Object-Lock)1 Tag
5cRestore-Drill-Cron + Tenant-Board-UI für Restore2 Tage
6Update-Pipeline + Version-Registry + Staged-Rollout-Logik3 Tage
73-Tier-Pricing im Backend + Stripe-Hookup2 Tage
8Enterprise-Onboarding-UI (DNS-Anweisung, "Test DNS"-Button, Cert-Provisioning)3 Tage
9Deprovision 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

RisikoMitigation
Postgres-Memory-Spike bei 25 Tenantsshared_buffers=32MB hardcoded im Template, max_connections=50
Backup-Konsistenz mit naivem tar über laufendes PostgresStop-Tar-Start (10s Downtime), Phase 2 → pg_basebackup
Backup-Encryption-Key-Verlust = Total-DatenverlustMaster-Key in 2 separaten Locations (Vault + YubiKey-Backup), dokumentierte Recovery-Prozedur, Restore-Drill prüft auch Key-Erreichbarkeit
Ransomware verschlüsselt alle BackupsObject-Lock auf L2/L3 (write-once-read-many, immutable), Compliance-Override-Prozess für legale Löschung
Cert-Renewal bei Enterprise-CNAME-Änderung durch KundeMonitoring auf Cert-Validity + Alert bei <14 Tagen
One-Time-Migration der 4 Tenants verläuft nicht reibungslosRollback-Script: alte Stacks bleiben 7 Tage warm-stehen, schneller Switch zurück möglich
Synapse-Federation bei Pro-Tenants weiter aktivDefault federation_domain_whitelist: [] für Free + Pro deaktivieren
Disk-IO bei 25 parallel laufenden Postgres-Instanzen mit WAL-SyncNVMe-only Hetzner-Plattform, kein Issue
MinIO 1-Disk-Setup: keine Erasure-Coding-Redundanz innerhalb Tenant-BoxAkzeptiert — Backup-Strategie kompensiert. Single-Disk-MinIO ist offiziell supported für Single-Drive Mode
Update bricht in Tenant-Box, Rollback-Snapshot ist auch korruptWö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-JobL0 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-ExtensionsVor 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.tpl
  • prilog-backend-api/src/services/tenant-box.service.ts — Backend-seitige Orchestrierung
  • prilog-backend-api/src/services/cert.service.ts — Let's-Encrypt-HTTP-01 für Enterprise-Custom-Domains
  • prilog-backend-api/src/services/backup.service.ts — Snapshot-Encryption, Object-Storage-Upload, Manifest-Tracking
  • prilog-backend-api/src/services/version-registry.service.ts — Version-Pinning, Rollout-State, Drift-Detection
  • prilog-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-Host
  • prilog-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 Postgres Provisioning (alle DBs werden migriert, Postgres-Service auf Shared-Host kann gestoppt werden)
  • shared MinIO Provisioning (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.ts Code-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.team mit 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).