Skip to content

Session-Recap 2026-05-02 — Tenant-Lifecycle, Backup, Auto-Scaling

Status: Alles produktiv. Beschreibt was am 2026-05-02 implementiert wurde.

Heute wurde die komplette Tenant-Box-Architektur ausgebaut — von 4 migrierten Live-Tenants über vollständigen Backup/Restore mit Hetzner Object Storage bis zu performance-basiertem Auto-Scaling mit aktivem Rebalance.

Übersicht

BlockWasDoku
AOld shared-PG/MinIO Deprovision + cloud-init Cleanuphier
BTenant-Lifecycle: Delete + Stripe + Orphanshier
CBackup-Pipeline scharf (Hetzner Object Storage, AES-256-CBC)hier
DInter-Host Migration via Backup/Restore-Pipelinehier
EAuto-Scaling: Performance-based + Active Rebalancehier
FAdmin-UI: Backups-Page + Restore-Button + Migration-Badgehier
GCI: deprecated-pattern checkhier
HMailPace Health-Check + Sidebar-Indikatorhier

10+ echte Bugs gefunden + permanent gefixt. Alles Source-fest.


Block A — Old Shared-Infrastructure Deprovision

Nachdem alle 4 Tenants migriert waren (demo3, demo2, demo, leander → Tenant-in-a-Box), wurde die alte Architektur abgerissen.

Was deprovisioniert wurde

shared-1 (49.12.108.38):

  • 7 alte synapse_*-DBs gedropped
  • 3 tenant-*-Buckets entfernt
  • /opt/prilog/tenants/* dirs cleaned
  • postgresql + minio Services stopped + disabled
  • 1 orphan synapse-e2e-test Container weg

shared-2 (91.99.190.150 — neue IP nach Re-Provisioning):

  • 1 verbliebene synapse_demo3 DB
  • 1 tenant-demo3 Bucket
  • 1 dir gecleant
  • Services gestoppt+disabled
  • DNS-Record shared-2.prilog.team korrigiert

Source-Fixes

shared-hosting.service.ts:buildSharedHostCloudInit — cloud-init für künftige Hosts installiert KEIN PostgreSQL und KEIN MinIO mehr (~50 Zeilen entfernt). mc-Client bleibt installiert (für Per-Tenant-MinIO-Operationen), aber kein fixer 'local' Alias mehr — Aliase werden pro Tenant dynamisch gesetzt.

tenant-box.service.ts:createTenantBox — schreibt jetzt automatisch TenantRuntime.s3Endpoint / s3Bucket / s3AccessKeyId / s3SecretAccessKeyEncrypted (HKDF-encrypted) pro neuem Tenant. Vorher mussten diese Felder manuell gesetzt werden.


Block B — Tenant-Lifecycle (Delete-Flow)

Vorher gab's KEINEN sauberen Tenant-Delete-Pfad. prisma.tenant.delete ließ Container/DB/Bucket/nginx als Zombies liegen. Stripe-Subscriptions wurden nicht gecancelt → Kunde wäre weiter belastet worden.

Neue Komponente: services/tenant-lifecycle.service.ts

Universeller deleteTenant({ orderId, actor, reason }):

  1. Stripe-Cancelstripe.subscriptions.cancel(...) wenn Subscription existiert (KRITISCH — verhindert weiterlaufende Belastung)
  2. Tenant-Box destroy via Agent (tenant-box.destroy purge=true) → Container + Verzeichnis weg
  3. Legacy-Cleanup via Agent (tenant-box.legacy_cleanup) → räumt residuale shared-PG-DB, shared-MinIO-Bucket, /opt/prilog/tenants/-dir auf
  4. DNS-Records bei Bunny löschen (teamRecordId + chatRecordId aus TenantSetting)
  5. Orphan-Tabellen-Cleanup — 12 Tabellen mit tenant_id als String (kein FK-Cascade): tenant_settings, process_templates, smoke_test_runs, module_events, tenant_migrations, tenant_marketplace_subscriptions, version_drift, tenant_box_rollout u.a.
  6. DB-Cascade-Delete — ServerOrder.deleteMany (FK-Restrict zu tenant!) + Transaction für verwaiste Tabellen + tenant.delete
  7. Audit-Log mit action: 'GDPR_ART17_FULL_DELETE'

Cron-Hookup freemium-wipe.service.ts

Wenn Kunde "Konto schließen" macht (30d-Frist) → Daily-Cron 04:30 ruft wipeTenant(tenantId) → das ruft jetzt tenant-lifecycle.deleteTenant für jede zugehörige Order. Damit räumt das System bei Self-Service-Schließung auch die Infrastruktur mit weg, nicht nur die DB.

Admin-UI

Tenant-Board Card:

  • Hover → Trash-Icon → "Vollständig löschen"-Dialog mit Subdomain-Eingabe als Bestätigung
  • "Kundengelöscht"-Badge bei subscriptionStatus='cancelled' + scheduledDeletionAt gesetzt → roter Border + Wipe-Datum

Order-Detail Page/orders/:id/purge-Endpoint detektiert hostingType='shared' → ruft tenant-lifecycle (statt nur Hetzner-Server-Delete + DB-Cascade). Dedicated-Pfad bleibt unverändert.

Was bleibt erhalten (Compliance)

tenant_id als String-Reference — diese Tabellen bleiben für 7-10y Retention:

  • invoices + invoice_items (Buchhaltung)
  • impersonation_logs (Audit)
  • email_logs (Stalwart)
  • child_protection_cases, avv_consent_logs, postfach_audit_logs, crisis_audit_entries
  • user_directory_history
  • tenant_backup + tenant_backup_audit (DSGVO-Beleg dass Backup gelöscht wurde)

Block C — Backup-Pipeline (Phase B scharf)

Vor heute war Backup nur Scaffolding (Master-Key fehlt, S3-Creds fehlen, Agent-Handler war Stub). Heute scharf geschaltet.

Setup

  • Hetzner Object Storage Bucket prilog-l1-daily-prod in Frankfurt
  • BACKUP_MASTER_KEY generiert (32 Bytes, hex) und in .env persistiert (chmod 600)
  • S3-Credentials scoped auf den einen Bucket
  • Encryption AES-256-CBC mit per-tenant-Key via HKDF-SHA256(master_key, tenant_slug + version)

Agent: tenant-box.backup

1. docker compose stop postgres (atomares DB-State)
2. tar -cf - /srv/tenants/<slug>/ | pigz > /var/lib/prilog/snapshots/<slug>-<ts>.tar.gz
3. docker compose start postgres (Tenant wieder schreibfähig — ~10s Downtime total)
4. SHA256 vom plaintext-Tarball
5. openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 → .tar.gz.enc
6. mc cp <enc> hetzner-alias/bucket/key
7. lokales File + alias cleanup

Agent: tenant-box.restore

1. (in-place) docker compose down -v + rm -rf /srv/tenants/<slug>/
2. mc cp hetzner-alias/bucket/key /tmp/staging/
3. openssl enc -d → .tar.gz
4. SHA256 verify gegen expected (Backup-DB-Eintrag)
5. tar -xzf → /srv/tenants/<slug>/
6. chown postgres-data (UID 70), media_store + signing.key (UID 991)
7. docker compose up -d postgres minio (warten healthy)
8. docker compose up -d synapse
9. Verify-Loop 18×10s = 3min (Synapse-Schema-Init nach Restore kann dauern)
10. nginx-Block aus manifest.json neu schreiben + reload

Backend: services/backup.service.ts

  • createBackup({ tenantSlug, hostId, tier, actor }) → Pre-Insert in tenant_backup mit status=pending → Agent-Call → Update auf status=verified mit size/sha256 → Audit-Log
  • restoreBackup({ backupId, targetHostId, inPlace, actor, reason }) → Lookup Backup → Agent-Call mit S3-Creds + Encryption-Key + expected SHA256 → DB-Update (serverOrder.synapsePort + sharedHostId)
  • listBackups() + listAuditLog() für Admin-UI
  • cleanupExpiredBackups() für Retention-Cron

Cron-Schedules

CronScheduleWas
tenant-box-daily-backuptäglich 02:00 UTC, 30s gestaffeltBackup pro Tenant
tenant-backup-cleanuptäglich 04:45 UTCRetention-Enforcement (30d)

Admin-UI

Neue /backups-Page (Sidebar: "Backups"):

  • Tab "Snapshots": Tabelle Tenant/Tier/Status/Erstellt/Größe/Retention
  • Tab "Audit-Log": wer/wann/was
  • Filter (Tenant-Name oder Aktion)
  • ⚡ "Wiederherstellen"-Button pro verified Snapshot

Tenant-Board Card kriegt Backup-Status:

    1. Status-Dot rechts oben (grün <26h, gelb 26-50h, rot >50h)
  • Footer zeigt Backup-Alter ("0h", "5h" etc.)
  • Hover → ⚡-Button → Confirm-Dialog → Restore in-place

Notfall-Workflow (panic-proof getestet):

  1. Tenant hat Probleme → Tenant-Board öffnen
  2. Hover → ⚡ klicken
  3. Dialog erklärt was passiert + geschätzte Dauer
  4. "JETZT WIEDERHERSTELLEN" → 30s später Tenant wieder online

Verifiziert mit demo3-Restore: 29 Sekunden gesamt, alle Daten 1:1 erhalten.


Block D — Inter-Host Migration via Tenant-Box-Pipeline

Drag-and-Drop im Tenant-Board nutzte vorher die alte pg_dump-Pipeline (tenant.snapshottenant.transfertenant.restore). Beides war broken seit Deprovisioning der shared-PG/MinIO. Komplett umgestellt auf Tenant-Box-Pipeline.

Neue Implementierung in tenant-migration.service.ts

runMigrationViaTenantBox() — 8 Steps:

  1. preflight — Target-Status='active', Backup-Pipeline configured
  2. freeze — TenantSetting maintenance.frozen=migration
  3. snapshotcreateBackup → Pre-Update-Tarball auf S3 (~2s)
  4. restorerestoreBackup mit targetHostId → neue Box auf Target-Host (~30s)
  5. cutover — Bunny DNS-Update auf Target-IP (delete+create → neue Record-IDs persistieren!)
  6. cleanuptenant-box.destroy purge=true auf Source-Host
  7. verifyfetch https://<domain>/_matrix/client/versions mit 12×10s Retry-Loop (DNS-TTL ist 300s)
  8. unfreeze — TenantSetting löschen, status=completed

Old code path

runMigration umbenannt zu runMigrationLegacy mit eslint-disable @typescript-eslint/no-unused-vars Marker. Bleibt im Repo für Rollback-Zwecke, wird nicht mehr aufgerufen. CI-Check verhindert dass es versehentlich wieder benutzt wird.


Block E — Auto-Scaling

Performance-basierte Host-Auswahl

findAvailableSharedHost() berechnet pro Host einen Score = max(cpu_pct, ram_pct, disk_pct) im sliding 10-Minuten-Window aus host_metrics. Wählt den Host mit niedrigstem Score, der unter HOST_FULL_THRESHOLD_PCT = 75 liegt.

Vorher: count-basiert (tenant count < maxTenants). maxTenants=15 war eine grobe Schätzung; tatsächliche Kapazität hängt von Performance-Auslastung ab.

Proaktiver Buffer (5min Cron)

shared-host-auto-scale Cron alle 5 Minuten:

  • Wenn alle Hosts ≥75% UND kein Host gerade in Provisioning → createSharedHost() (Hetzner CCX23, ~10min)
  • Damit gibt's IMMER einen Host mit Platz, kein neuer Tenant wartet

Active Rebalance (15min Cron)

shared-host-rebalance Cron alle 15 Minuten:

  1. In-flight check: Skipt wenn schon eine Migration läuft
  2. Source: Host mit höchstem Score über HOST_OVERLOAD_THRESHOLD_PCT = 85
  3. Target: Host mit niedrigstem Score, Score < 75% UND Source-Score - Target-Score ≥ REBALANCE_DELTA_PCT = 30
  4. Tenant-Wahl: jüngster Tenant auf Source (= least Daten = günstigste Migration), mit REBALANCE_COOLDOWN_HOURS = 6 Hysterese (verhindert Bounce-Loop)
  5. Triggert startMigration({ initiatedBy: 'cron-rebalance' }) → läuft durch die normale Migration-Pipeline

Schwellenwerte (in shared-hosting.service.ts)

KonstanteWertBedeutung
HOST_FULL_THRESHOLD_PCT75Über diesem Wert keine neuen Tenants
HOST_OVERLOAD_THRESHOLD_PCT85Triggert Active Rebalance
REBALANCE_DELTA_PCT30Min Score-Differenz Source/Target
REBALANCE_COOLDOWN_HOURS6Pro Tenant max alle 6h
METRICS_WINDOW_MIN10Sliding-Window für Score-Berechnung

Block F — Admin-UI: Audit-Spuren

Migration-Badge auf Tenant-Card

Wenn lastMigrationAt gesetzt → Badge zwischen Header und Footer:

  • BLAU bei Cron: "Auto-Rebalanced"
  • GRAU bei manuell: "Migriert"
  • Footer: "vor 25min" / "vor 3h" / "vor 2d"
  • Tooltip: shared-1 → shared-2 · 02.05.2026 12:34 · Auto-Rebalance (Cron)

"Kundengelöscht"-Badge

Wenn subscriptionStatus='cancelled' + scheduledDeletionAt → roter Border + Badge mit Wipe-Datum.

Tenant-Card kompletter Indikator-Stack

┌─────────────────────────────┐
│  ●  Tenant Name        ●    │  ← Status + Backup-Status (2 Dots)
│     @subdomain              │
├─────────────────────────────┤
│ → Auto-Rebalanced  vor 25min│  ← (optional, Migration-Badge)
├─────────────────────────────┤
│ 🗑 Kundengelöscht  → 02.06.26│  ← (optional, Self-Service-Schließung)
├─────────────────────────────┤
│ 👤 5  ·  :8101  · 📁 3h     │  ← Users · Port · Backup-Alter
└─────────────────────────────┘

Block G — CI: Deprecated-Pattern Check

Neues Script scripts/check-deprecated-patterns.sh läuft in CI (.github/workflows/test.yml) — fail-fast bei Match.

Aktuell gechecked

  1. sendCommand('tenant.snapshot|transfer|restore') — alte pg_dump-Pipeline
  2. sendCommand('shared_tenant.create') — alte PG/MinIO-Provisioning
  3. subscribeToCommandResult direkt (statt callAgentForHost) — Race-Risk
  4. prisma.tenant.delete ohne tenant-lifecycle — Zombie-Risk
  5. host.docker.internal:5432 — shared-PG ist deprovisioniert

Pfad-Excludes

  • runMigrationLegacy für Rollback erhalten
  • orders.router.ts dedicated-Pfad ist OK (kein shared-host-Agent zum Cleanup)
  • 8 bekannte subscribeToCommandResult-Stellen als TODO incremental migrate

Sobald jemand neuen Code schreibt der ein verbotenes Pattern nutzt → CI rot.


Block H — MailPace Health-Check + Sidebar-Indikator

Vorher tauchten ungültige MAILPACE_API_KEY erst beim ersten fehlgeschlagenen Welcome-Mail auf. Operator wusste nicht warum kein Tenant Mails bekam (siehe andreas-Bug 2026-05-02).

Backend: services/mailpace.service.ts

checkMailpaceHealth() macht POST mit Empty-Body:

  • MailPace antwortet 401/403 wenn Token ungültig
  • 422 oder 2xx wenn Token gültig (validation error wegen Empty-Body, KEINE E-Mail verbraucht)

getMailpaceHealth() cached den letzten Check.

Trigger

  • Backend-Startup ruft checkMailpaceHealth() automatisch auf — bei Fehler ERROR-Log: "Token ungültig, KEINE Mails werden versendet"
  • GET /admin/mailpace/health für Admin-UI-Polling
  • POST /admin/mailpace/health/recheck für Re-Check ohne Backend-Restart (z.B. nach Token-Update in .env)

Admin-UI: Sidebar-Indikator

Sidebar pollt alle 5min /admin/mailpace/health. Bei ok=false:

  • Rotes Banner direkt über dem Theme-Toggle: "Mail-Pipeline kaputt — Welcome-Mails kommen nicht an"
  • "Re-Check"-Button → triggert sofortigen Re-Check (für nach Token-Update)
  • Tooltip mit Detail-Error

Wenn ok=true ist das Banner unsichtbar.

Block I — Shared-Provisioning Finalize-Helper

Vorher lag die ganze Post-Provisioning-Logik (Status='active', Admin-User-Eintrag, Default-Rollen, Process-Engine-Seeds, Welcome-Mail) als ~80 Zeilen inline in agent-ws.ts und wurde durch payload.sharedTenantOrderId getriggert. Beim Wechsel auf tenant-box.create (sendet das Feld nicht) lief NICHTS davon → andreas blieb 'pending', keine Mail.

Fix

services/shared-provisioning-finalize.service.ts extrahiert. Idempotent (prüft "exists" bevor write):

  1. status='active' + installationStatus='complete'
  2. UserDirectoryEntry + PrilogMembership für Admin
  3. Default-UserTypes (Schule: Mitarbeiter/Eltern/Schueler · sonst: Mitglied)
  4. Process-Engine Default-Templates
  5. Welcome-Mail (fail-soft)

Wird von BEIDEN Pfaden gerufen:

  • agent-ws.ts (legacy sharedTenantOrderId Trigger) — inline-Code gelöscht, ersetzt durch 1-Zeilen-Call
  • shared-hosting.service.ts:autoProvisionSharedTenant (neuer tenant-box-Pfad) — explizit gerufen nach createTenantBox

CI-Pattern-Checks ergänzt:

  • userDirectoryEntry.create admin: true außerhalb des finalize-Helpers → CI fail
  • seedProcessEngineDefaults inline (außer im Service oder finalize) → CI fail

Bugs gefunden + permanent gefixt (heute)

  1. Web-Client missing on shared-2 → cloud-init zieht jetzt /api/artifacts/latest, agent-on-connect Fallback
  2. stale synapse-tailscale.conf auf shared-1 (Port 8101 von alter demo) — manuell entfernt
  3. minioRootUser min(8) zu strikt → reduziert auf min(3) (matches MinIO requirement, sonst tb-demo = 7 chars failed)
  4. ServerOrder FK Restrict — tenant.delete warf "Foreign key constraint violated" → tenant-lifecycle löscht jetzt ServerOrder VOR Tenant
  5. Stripe-Subscriptions liefen nach Tenant-Delete weiter → tenant-lifecycle cancelt jetzt zuerst Stripe (Step 0)
  6. 12 Orphan-Tabellen mit String-tenantId blieben liegen (process_templates, tenant_settings etc.) → tenant-lifecycle cleant generisch
  7. Race-Condition in sendAgentCommand — Listener wurde NACH sendCommand registriert, schnelle Agent-Antworten (1-2s) gingen verloren → neue lib/agent-command.ts mit Listener-First-Pattern, alle 4 Services nutzen den shared Helper
  8. restoreBackup ignorierte targetHostId bei inPlace=true → Inter-Host-Migration schickte restore an SOURCE statt TARGET → Cleanup zerstörte den frisch restored Container → fix: targetHostId hat Vorrang
  9. Bunny DNS POST 405 für update — sporadischer API-Quirk → updateDnsRecord macht jetzt intern delete+create (gibt neue recordId zurück, Caller persistiert)
  10. shared-hosts DELETE löschte Hetzner-Server nicht → Orphan-Server liefen weiter und kosteten Geld → DELETE ruft jetzt zuerst hetzner.deleteServer (fail-soft mit Warnung)
  11. Tenant-Board zeigte "X / 15" — count-basierte Auslastung war nicht aussagekräftig → entfernt (Nenner weg, nur Tenant-Count)
  12. Quick-Signup hardcoded 20-Nutzer-Limit (Light-Relikt) → maxUsersShared=null, maxUsers=99999, DB-Update der 3 bestehenden Tenants. Web-Client zeigt User-Limit nicht mehr an
  13. MailPace Token-Probleme bemerkten erst beim ersten Send → Startup-Health-Check + Sidebar-Indikator + Re-Check-Endpoint
  14. Post-Provisioning-Logik nur an alte sharedTenantOrderId gekoppelt → andreas bekam keinen 'active'-Status, keine Welcome-Mail → finalize-Helper extrahiert, beide Pfade rufen ihn

Architektur-Status nach 2026-05-02

Vollständig produktiv:

CapabilityStatus
Tenant-in-a-Box pro Tenant✅ live, 4 Tenants migriert
Auto-Provision neue Hosts✅ via Hetzner API
Backup nach Hetzner Object Storage✅ täglich 02:00, 30d Retention
Restore in <30s✅ verifiziert
Inter-Host-Migration✅ via Tenant-Box-Pipeline
Auto-Scaling (Buffer + Rebalance)✅ 5min/15min Crons
Tenant-Lifecycle Delete✅ inkl. Stripe-Cancel + Orphan-Cleanup
Admin-UI vollständig✅ Backups-Page + Restore-Button + Badges
CI deprecated-pattern check
MailPace Health-Check✅ Startup + Sidebar-Indikator + Re-Check-Endpoint
Shared-Provisioning Finalize✅ Helper extrahiert, beide Pfade nutzen ihn

Offen (für später, siehe project_tenant_box_open_items.md Memory):

  • Backup L2 (monthly) + L3 (yearly) mit Object-Lock — aktuell nur L1 daily
  • Restore-Drill-Cron mit Sandbox-Host (proactive Backup-Korruption finden)
  • Update-Pipeline scharf (version-registry + staged rollout) — aktuell nur Scaffolding
  • Enterprise-Onboarding-UI (DNS-Anweisung + Cert-Provisioning) — aktuell nur cert-service.ts Stub
  • 8 incremental subscribeToCommandResult-Migrationen

Crons-Übersicht (alle live)

CronScheduleZweck
host-metrics-collectjede MinuteHetzner CPU/Disk/Network → host_metrics
host-metrics-cleanuptäglich 04:00host_metrics >30d löschen
shared-host-auto-scalealle 5minBuffer-Host provisionieren wenn alle >75%
shared-host-rebalancealle 15minTenant von >85% auf <55% migrieren
tenant-box-daily-backuptäglich 02:00Backup pro Tenant → Hetzner Object Storage
tenant-backup-cleanuptäglich 04:45Backup-Retention enforcement
freemium-tenant-wipetäglich 04:3030d Konto-Schließung → tenant-lifecycle
freemium-monthly-billing1. um 06:00Per-Active-User Billing
marketplace-monthly-billing1. um 06:30Marketplace Usage-Records
module-data-cleanuptäglich 03:00Modul-Daten 30d nach Deactivate
file-cleanuptäglich 03:30Soft-deleted Files in S3
storage-rotationtäglich 04:00MinIO SvcAcct Rotation 90d

Alle sichtbar im Admin unter /cron-jobs mit Run-History + Status.