Tenant-Boxes sicher updaten — ein Tutorial
Dieses Tutorial erklärt von Grund auf, wie Updates auf den Prilog-Tenant-Boxes funktionieren — und vor allem warum sie so funktionieren, wie sie es tun. Es richtet sich an Operatoren (= dich), die Synapse, Postgres oder MinIO auf einem oder mehreren Tenants aktualisieren wollen, ohne nachts wach zu liegen.
Wenn du nach diesem Tutorial einen Pilot-Rollout selbstständig fahren kannst und im Kopf hast, was bei einem Fehler passiert, ist das Ziel erreicht.
Warum nicht einfach docker compose pull && up -d?
Weil ein Tenant kein Lab ist. In jeder Tenant-Box läuft echter Schul-Alltag:
- Eltern schreiben Nachrichten in Klassen-Räume.
- Lehrkräfte legen gerade Aufgaben an.
- Im DMS werden Dateien hochgeladen.
- Der Synapse-Sync-Loop hat offene Long-Polls aus 50 Browser-Tabs.
Ein "einfaches" Update kann da viele Arten schief gehen:
- Synapse v1.124 startet nicht wegen eines neuen Configs-Defaults — Chat ist tot, niemand kann sich anmelden.
- Postgres-Major-Bump transformiert das Daten-Format, neue Synapse-Version hat eine fehlerhafte Migration — Daten korrupt, Restore aus Backup nötig.
- MinIO läuft, aber eine geänderte Auth-Header macht S3-Uploads broken — niemand kann mehr Anhänge schicken.
In jedem dieser Fälle willst du nicht "manuell debuggen während die Schule gerade Pause macht". Du willst:
- Vor dem Update einen Stand zum Zurückspringen.
- Während dem Update sehen, ob es funktioniert.
- Nach dem Update automatisch zurückrollen, wenn die Box nicht antwortet.
Genau das macht die Update-Pipeline.
Die zwei Schichten
Die Pipeline hat zwei klar getrennte Teile:
┌─────────────────┐ ┌─────────────────┐
│ ERKENNEN │ │ AUSFÜHREN │
│ Drift- │ ───▶ │ Update- │
│ Detection │ │ Pipeline │
│ │ │ │
│ Was ist Soll? │ │ Wie kommen │
│ Was ist Ist? │ │ wir vom Ist │
│ Wo sind wir? │ │ zum Soll? │
└─────────────────┘ └─────────────────┘
passiv, read-only aktiv, manuell
stündlich Cron gestartetDrift-Detection läuft permanent im Hintergrund und beantwortet die Frage: "Welche Tenant-Box hat welche Versionen, und was ist die Differenz zum Soll-Zustand?"
Die Update-Pipeline ist getrennt und läuft nur, wenn du sie explizit startest. Kein Cron sagt "hey, da ist Drift, ich update mal eben".
Das ist Absicht: Erkennen ≠ Ausführen. Du siehst die Fakten, du entscheidest, du startest. Die Pipeline macht dann den Rest.
Single Source of Truth: das Versions-Manifest
Bevor wir über Drift reden können, brauchen wir einen Soll-Zustand. Den liefert das Manifest in prilog-backend-api/src/services/tenant-box-versions.ts:
export const TENANT_BOX_MANIFEST = {
manifestVersion: 1,
generatedAt: '2026-05-08T00:00:00Z',
tiers: {
free: {
synapse: 'v1.124.0',
postgres: '16-alpine',
minio: 'RELEASE.2024-12-13T22-19-12Z',
migrationsHead: '0065_rollout_target_slugs',
...
},
pro: { ... }, // Heute identisch zu free
enterprise: { ... }, // Heute identisch zu free
},
};Das Manifest ist TypeScript-Code, kein YAML in der DB. Jede Änderung geht durch Pull-Request, Code-Review, Git-History. Ein Versions-Bump ist ein Commit.
Warum nicht in der DB? Wenn Versionen in der DB stünden, könnte sie jeder Admin ohne Audit ändern. Bei Synapse oder Postgres geht's um CVE-Tracking und Migrations-Verträglichkeit — da willst du, dass Änderungen denselben Review-Prozess durchlaufen wie der Code, nicht der schnelle "Klick in der UI".
Pro Tier gibt es einen separaten Eintrag. Heute laufen alle Tiers identisch, aber die Struktur ermöglicht später, Free-Kunden auf einer neueren Version laufen zu lassen während Enterprise-Kunden noch eine Phase warten.
Wie funktioniert Drift-Detection?
Stündlich läuft der Cron tenant-box-drift-detection. Er macht zwei Dinge:
- Liest aus
tenant_box_registrydie installed Versionen pro Tenant. Diese werden vom anderen Cron (tenant-box-version-sync, täglich) per Agent-Calltenant-box.report_versionsbefüllt — also eindocker compose ps --format jsondirekt auf dem Tenant-Host. - Vergleicht mit dem Manifest und schreibt das Diff in
tenant_box_registry.drift_report(JSONB).
Das Diff hat eine Severity:
| Komponente | Diff-Art | Severity |
|---|---|---|
migrationsHead | beliebige Abweichung | major |
synapse | Major-Version-Bump (v1.x → v2.x) | major |
synapse | Minor/Patch | minor |
synapse | installed = latest oder leer | major (unbekannt = unsicher) |
postgres | Major-Bump (16 → 17) | major |
postgres | Minor/Patch | minor |
minio | beliebig | minor |
Pro Tenant wird das schwerste Item als Tenant-Severity genommen.
Das landet alles im Admin-UI unter /tenant-drift:
- Liste aller Tenants mit Severity-Badge (none/minor/major/critical/unknown)
- Klick auf eine Zeile öffnet ein Detail-Drawer mit der Soll/Ist-Tabelle
- "Sofort-Scan auslösen"-Button für eine Ad-hoc-Aktualisierung
Wichtig zu verstehen: Drift-Detection berührt nichts. Sie liest, sie vergleicht, sie schreibt einen Report. Mehr nicht.
Anatomie eines Updates
So sieht ein Tenant-Update von außen aus, wenn die Pipeline läuft:
┌──────────────────────────────────────────────────────────────┐
│ │
│ 1. Pre-Snapshot (createBackup, tier='pre-update') │
│ ├─ Postgres stop │
│ ├─ tar /srv/tenants/<slug>/ │
│ ├─ AES-256 encrypt mit per-Tenant-Key (HKDF) │
│ ├─ Upload zu Hetzner-S3 │
│ └─ Postgres start │
│ │
│ 2. Update (Agent: tenant-box.update) │
│ ├─ docker-compose.yml: Image-Tags ersetzen │
│ ├─ Backup-Datei docker-compose.yml.before-update.<ts> │
│ ├─ docker compose pull <changed-services> │
│ └─ docker compose up -d <changed-services> │
│ │
│ 3. Health-Probe (Agent: tenant-box.healthcheck) │
│ ├─ HTTP /_matrix/client/versions → 200 │
│ ├─ HTTP /_matrix/client/v3/sync → 2xx-4xx │
│ ├─ pg_isready → exit 0 │
│ └─ HTTP /minio/health/live → 200 │
│ ↻ wiederholen alle 60s, bis 3× in Folge ok │
│ │
│ 4. Action.status = 'health_ok' │
│ 5. Registry: synapse_version, postgres_version aktualisiert │
│ │
└──────────────────────────────────────────────────────────────┘Wenn irgendwo dazwischen etwas schief geht, springt die Engine in den Catch-Pfad:
┌──────────────────────────────────────────────────────────────┐
│ │
│ AUTO-ROLLBACK │
│ │
│ 1. Action.status = 'failed' + error_message │
│ 2. restoreBackup({ backupId: <pre-update-snapshot> }) │
│ ├─ docker compose down -v │
│ ├─ Download .enc + Decrypt │
│ ├─ SHA256 verify │
│ ├─ tar -xzf nach /srv/tenants/<slug>/ │
│ └─ docker compose up -d │
│ 3. Action.status = 'rolled_back' │
│ 4. Tenant ist im Vor-Update-Zustand. Keine Daten verloren. │
│ │
└──────────────────────────────────────────────────────────────┘Der Pre-Snapshot ist nicht optional. Wenn die Backup-Pipeline nicht konfiguriert ist (BACKUP_MASTER_KEY fehlt), verweigert die Engine den Start. Ohne Restore-Pfad kein Update.
Praxis: ein Pilot-Update Schritt für Schritt
Sagen wir, das Manifest wurde gerade auf synapse: v1.124.1 gebumpt (Patch-Release mit Sicherheits-Fix). Du willst das Update auf deinem Test-Tenant demo ausprobieren, bevor du es auf leander (echte Schule) fährst.
Schritt 1: Drift sehen
Im Admin-UI: /tenant-drift → "Sofort-Scan auslösen".
Du siehst eine Tabelle:
| Slug | Tier | Severity | Drift-Items |
|---|---|---|---|
| demo | pro | minor | synapse |
| leander | pro | minor | synapse |
| andreas | pro | minor | synapse |
Klick auf demo öffnet das Detail-Drawer:
Erwartete Versionen (Manifest)
synapse: v1.124.1
postgres: 16-alpine
minio: RELEASE.2024-12-13...
migrationsHead: 0065_rollout_target_slugs
Installiert
synapse: v1.124.0 ← drifted
postgres: 16-alpine
minio: RELEASE.2024-12-13...
migrationsHead: 0065_rollout_target_slugs
Drift-Items
synapse: installed=v1.124.0, expected=v1.124.1, severity=minorJetzt weißt du genau, was sich ändern wird.
Schritt 2: Pilot-Rollout anlegen
Im Admin-UI: /tenant-rollouts → "Neuer Rollout".
- Versionen werden automatisch aus dem Manifest gezogen.
- Im Feld Target-Slugs:
demo(genau dieses eine Wort).
Klick "Rollout queuen". Du landest in der Liste mit einem neuen Eintrag, Status queued. Es passiert noch nichts.
Warum target_slugs? Ohne Target-Slugs würde die Engine den klassischen Tier-Rollout fahren (canary → free_5 → free_100 → pro → enterprise). Mit Target-Slugs sagst du: "Genau diese Tenants, einmal, fertig." Das ist der Pilot-Modus. Die Action-Stage in der Tabelle heißt dann
pilotstattcanary, damit klar ist, was passiert ist.
Schritt 3: Detail-Page öffnen, "Start" klicken
Klick auf die Rollout-ID. Du siehst:
- Lifecycle-Buttons: Start, Pause, Resume, Abort
- Aktions-Tabelle (leer, weil noch nicht gestartet)
- Audit-Log + Lifecycle-Zeitstempel
"Start" klicken. Die Engine läuft im Hintergrund (asynchron — Browser hängt nicht).
Auto-Refresh alle 5s zeigt dir, was passiert:
Tenant Stage Status Snapshot Dauer Fehler
─────────────────────────────────────────────────────────
demo pilot pending — — —
↓
demo pilot snapshot_ok #47 — —
↓
demo pilot updated #47 — —
↓ (3-5 Min Health-Probe-Loop)
demo pilot health_ok #47 4.2 Min —Wenn health_ok erreicht ist, springt der Rollout-Status auf done.
Schritt 4: Verifizieren
Zurück zu /tenant-drift → "Sofort-Scan auslösen". demo sollte jetzt als synchron (severity none) angezeigt werden.
In der Registry:
SELECT slug, synapse_version FROM tenant_box_registry WHERE slug = 'demo';
-- demo | v1.124.1Pre-Snapshot ist im S3-Bucket gelandet als pre-update-Backup. Du kannst ihn unter /admin/backups sehen.
Schritt 5: Ausrollen auf die anderen
Wenn der Pilot eine Stunde stabil läuft (oder über Nacht), kannst du den zweiten Rollout starten:
- Target-Slugs:
leander, andreas(komma-getrennt) - Start
Concurrency-Limit ist 3 — beide laufen parallel, Pipeline ist nach ~5 Min durch.
Oder du fährst ohne Target-Slugs einen klassischen Tier-Rollout. Dann geht es durch alle Phasen mit Wartezeit-Möglichkeit dazwischen.
Wenn etwas schiefgeht — eine Geschichte aus dem echten Leben
Am 8. Mai 2026 war die Engine zum ersten Mal scharf. Der erste Pilot auf demo durchlief sauber: Pre-Snapshot ✓, Update ✓, Health-Probe 3× ok ✓.
Dann brach die Action mit einem Postgres-Fehler ab:
ERROR: column "health_check_result" is of type jsonb
but expression is of type textIm Code wurde das JSONB-Feld als String gebunden, ohne expliziten Cast. Das war mein Bug, nicht ein Synapse-Problem.
Was die Engine getan hat:
- Sie sah einen Exception in der Update-Loop.
- Sie wusste nicht, ob der Bug in Synapse, im Update-Pfad, oder im Schreib-Pfad lag.
- Sie machte deshalb das Sicherste:
restoreBackupaus dem Pre-Snapshot. demowar ~5 Minuten auf der neuen Version, und ist dann sauber zurück auf die alte.
Status nach dem Vorfall:
- Action:
rolled_back - Tenant: vollständig im Vor-Update-Zustand (Synapse-Container, DB, Bucket, alles)
- Daten: nichts verloren — Postgres und MinIO wurden während des Updates gar nicht angefasst, nur der Synapse-Container wurde getauscht
- Pre-Snapshot: bleibt im S3, kann manuell gelöscht oder zur Forensik behalten werden
Das ist der Wert der zwei-stufigen Architektur: es ist egal warum etwas schiefgeht. Code-Bug, Synapse-Crash, Disk voll, Netz weg. Die Engine setzt zurück, dokumentiert, ruft uns. Sie versucht nicht zu raten, sie versucht nicht weiterzumachen, sie versucht nicht "halb-richtig" zu sein.
Der Bug wurde dann sauber gefixt (JSONB-Cast in der Schreib-Funktion), das nächste Update lief komplett durch.
Sicherheits-Garantien — was die Pipeline garantiert
Kein Auto-Start. Die Engine läuft nur, wenn ein Mensch
POST /admin/tenant-boxes/rollouts/:id/starttriggert. Kein Cron, kein Webhook, kein Drift-Trigger.Pre-Snapshot ist Pflicht. Ohne
BACKUP_MASTER_KEYund konfigurierte S3-Credentials startet die Engine nicht. Die Idee, "ohne Backup mal schnell" ist konstruktiv ausgeschlossen.Auto-Rollback bei jedem Fail. Update-Fehler, Health-Probe-Fail nach 30 Min, Schreib-Fehler in der DB — alle führen zum gleichen Pfad:
restoreBackupaus dem Pre-Snapshot.Concurrency-Limit von 3. Innerhalb einer Phase werden maximal 3 Tenants parallel angefasst. Bei 50 Pro-Tenants auf demselben Host wird der Host nicht durch parallele
compose pull-Operationen erstickt.Cancellation-aware. Jede 5 Sekunden pollt die Engine den Rollout-Status. Wenn du auf "Pause" klickst, beendet sie sich nach der gerade laufenden Tenant-Aktion (saubere Zwischenfreigabe).
Phase pausiert bei Failure. Wenn ≥1 Tenant in einer Phase fehlschlägt, advanced die Engine nicht zur nächsten Phase. Sie pausiert mit dem Reason
Phase X: N Failures. Du musst manuell entscheiden — Resume (versuchen) oder Abort (terminieren).
CVE-Hotpath für Notfälle
Sagen wir, eine kritische CVE in Synapse wird bekannt — Remote Code Execution, alle Versionen vor v1.124.5 betroffen. Du kannst nicht 5 Tage Canary-Beobachtung leisten. Du brauchst alle Tenants auf der Patch-Version, sofort.
Der CVE-Hotpath ist der Override:
curl -X POST https://api.prilog.chat/api/admin/tenant-boxes/cve-hotpath \
-H "Authorization: Bearer $ADMIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"reason": "CVE-2026-0001 RCE in Synapse media-repo",
"toSynapse": "v1.124.5",
"toPostgres": "16-alpine",
"toMinio": "RELEASE.2024-12-13T22-19-12Z",
"toMigrationsHead": "0065_rollout_target_slugs",
"approvalToken": "<KLARTEXT>",
"triggeredBy": "lee@prilog.team"
}'Der Hotpath validiert:
- Approval-Token wird gegen
CVE_APPROVAL_TOKEN_HASHenv (sha256) in konstanter Zeit verglichen. Ohne den Token: 401. - Reason muss die CVE-ID enthalten (≥ 8 Zeichen).
- Backup-Pipeline muss konfiguriert sein — Pre-Snapshot ist auch im Notfall Pflicht.
Reihenfolge im Hotpath: pro → enterprise → free_100. Pro/Enterprise zuerst, weil dort SLA-Druck. Free zuletzt, weil Schadensvolumen pro Schule kleiner und mehr Beobachtungs-Zeit.
Warum nicht UI-Button? Der CVE-Hotpath bewusst keine UI. Wer ihn ausführt, soll wissen was er tut, und der Approval-Token soll nirgends im UI-State liegen. Eine UI-Button wäre genau die Backdoor, durch die im Ernstfall die kaputten Dinge rausgehen.
Was die Pipeline NICHT macht
Wichtig zu wissen, damit du nicht falsche Erwartungen hast:
Postgres-Major-Upgrades (16 → 17) sind nicht idempotent über
compose upmachbar. pg_upgrade-Tooling mit dual-volume und Schema- Migration ist eigene Stufe (Stufe 5 in der Phase-6-Roadmap), Spike noch offen. Bis dahin: Postgres bleibt auf 16, Manifest sagt 16, Drift würde Major-Bump alsmajormarkieren — du musst dann vorsichtig manuell migrieren.Module-Updates (z.B. ein installiertes Plugin auf Version X.Y bumpen) laufen nicht durch diese Pipeline. Modul-Lifecycle ist separater Pfad über die Marketplace-Phase.
Synapse-Konfigurations-Bumps (neue Module aktivieren, Federation anders konfigurieren) gehen über
tenant-box.updatemit anderen Args, nicht über einen Versions-Bump. Eigener Pfad.Backend-API-Updates (das hier beschriebene Backend selbst). Das ist ein zentraler Service, kein Tenant-spezifischer Container. Backend- Deploys laufen über
git pull && pm2 restartauf dem zentralen Host, nicht durch diese Pipeline.Tier-Wartezeiten zwischen Phasen. Heute schaltet die Engine sofort weiter, wenn eine Phase ohne Failures durch ist. Wenn du 24h zwischen Canary und Free 5% beobachten willst: Pause, beobachten, Resume. Automatische Wartezeiten kommen mit Stufe 6 (Telemetrie).
Quick Reference
Cron-Jobs (im Admin unter /admin/crons)
| Key | Schedule | Was |
|---|---|---|
tenant-box-version-sync | 30 3 * * * | Holt Live-Versionen pro Tenant per Agent, schreibt in Registry |
tenant-box-drift-detection | 7 * * * * | Vergleicht Registry mit Manifest, schreibt drift_report |
tenant-box-daily-backup | 0 2 * * * | Tägliche Backups, gestaffelt 30s zwischen Tenants |
Wichtige Tabellen
tenant_box_registry— eine Zeile pro Tenant. Spaltensynapse_version,postgres_version,minio_version(von Versions-Sync gefüllt),drift_report(JSONB, von Drift-Cron gefüllt).update_rollouts— eine Zeile pro Rollout-Versuch. Status, kind, to-Versionen, target_slugs, audit_log (JSONB).update_rollout_actions— eine Zeile pro Tenant pro Rollout. Pre- Snapshot-Backup-ID, Status (pending/snapshot_ok/updated/health_ok/ failed/rolled_back), Health-Check-Result.
Code-Pfade
src/services/tenant-box-versions.ts— Manifestsrc/services/tenant-box-drift.service.ts— Drift-Detection-Servicesrc/services/tenant-box-version-sync.service.ts— Live-Scansrc/services/tenant-box-health.service.ts— Health-Probe-Wrappersrc/services/tenant-box-rollout.service.ts— State-Machine (pure)src/services/tenant-box-rollout-engine.service.ts— Orchestratorsrc/services/tenant-box-cve-hotpath.service.ts— CVE-Hotpath-Validierung
Häufige Aufgaben
Wo läuft welche Synapse-Version?/tenant-drift öffnen, Tabelle ist sortiert nach Severity. Falls installed=NULL: erst /admin/crons → tenant-box-version-sync → Trigger.
Manifest auf neue Synapse-Version bumpen?prilog-backend-api/src/services/tenant-box-versions.ts editieren, die drei Tier-Einträge auf den neuen Tag setzen, publishedAt hochziehen, Commit. Nach Backend-Deploy ist die Drift-Detection automatisch über die Änderung informiert.
Pilot-Update? Siehe Schritt 1-5 oben. target_slugs = der eine Tenant, fertig nach ~5 Min.
Notfall-Update wegen CVE? CVE-Hotpath-Endpoint mit Approval-Token. Siehe Abschnitt CVE-Hotpath oben.
Rollout pausieren? Detail-Page → "Pause"-Button. Engine beendet sich nach aktueller Tenant-Aktion. Resume oder Abort wenn du soweit bist.
Update läuft fest? Der Health-Probe wartet bis zu 30 Minuten auf 3× stable in Folge. Wenn ein Tenant nicht reagiert: nach 30 Min schlägt die Probe fehl, Auto- Rollback wird ausgelöst, Tenant ist zurück. Du kannst auch vorher manuell Abort klicken, dann läuft der gerade laufende Tenant aus, weitere werden übersprungen.
Weiterführend
- Konzept Phase 6:
tenant-box-phase-6-update.md— die Architektur-Entscheidungen im Detail, inkl. State-Diagramm und Roadmap zu Stufe 5 (pg_upgrade) und Stufe 6 (Telemetrie). - Tenant-in-a-Box-Konzept:
tenant-in-a-box-konzept.md— wie eine Tenant-Box von der Provisionierung bis zur Löschung funktioniert, was die Backup-Pipeline (Phase 5) ist, wie die Identität (server_name) invariant über den Lifecycle bleibt. - Disaster-Recovery-Konzept:
disaster-recovery-konzept.md— was passiert, wenn die ganze Backend-DB stirbt, nicht nur ein Tenant-Container.