Deploy: K3s (Production-Scale)
Pulumi-managed K3s-Deploy — ein Cluster, der jede Kumiko-gebaute App + die Plattform fährt. Läuft so in Production auf kumiko.so.
Ebenen
Der Cluster besteht aus drei aufeinander aufbauenden Pulumi-Stacks:
| Stack | Liegt in | Was er liefert |
|---|---|---|
| platform | infra/pulumi/platform/ | Hetzner-VMs, K3s-Install, Wireguard-VPN |
| operators | infra/pulumi/operators/ | ingress-nginx, cert-manager, CloudNativePG, Velero |
| sites | infra/pulumi/sites/ | Pro-App Deployment + Service + Ingress + Certificate |
platform einmal hochziehen, operators einmal, danach pro App einen Eintrag in sites ergänzen.
Neue Site hinzufügen
infra/pulumi/sites/index.ts nutzt einen createStaticSite-Helper, der das Deployment + Service + Ingress + Certificate-Combo bündelt:
createStaticSite({ name: "my-app", domain: "my-app.kumiko.so", image: "ghcr.io/your-org/my-app:latest", // ghcrPull nur falls das Image privat ist ghcrPull: { username: cfg.requireSecret("ghcrUser"), password: cfg.requireSecret("ghcrToken") }, k8sProvider,});Danach pulumi up aus infra/pulumi/sites/ ausführen.
Der Helper setzt:
imagePullPolicy: Always— zieht bei jedem Pod-Restart frisches:latestcert-manager.io/cluster-issuer: letsencrypt-prod-Annotation am Ingress- nginx-Ingress-Class
- DNS-Eintrag für die Domain (sofern der Cloudflare-Provider konfiguriert ist)
Rolling-Update bei neuem Image-Push
:latest-Tag + Always-Pull ist nur die halbe Miete. K8s startet Pods nicht neu, wenn der Image-Tag-String unverändert bleibt. Zwei Optionen:
- Annotation-Bump — einen unique Wert (z. B. SHA) in eine Pod-Template-Annotation des Deployments schreiben. K8s sieht den Spec-Wechsel → Rolling-Restart.
kubectl rollout restart deployment/my-app— expliziter Restart aus CI nach dem Image-Push.
Beides funktioniert. Option 1 hält Pulumi als Source-of-Truth; Option 2 ist ein zusätzlicher Schritt im Build-Workflow.
Migrate vor Pod-Start
K3s-Deployments nutzen Init-Container für den Pre-Deploy-Migrate-Step:
spec: template: spec: initContainers: - name: migrate image: ghcr.io/your-org/my-app:latest command: ["bun", "/app/kumiko.js", "migrate", "apply"] env: - name: DATABASE_URL valueFrom: { secretKeyRef: { name: my-app-db, key: url } }Der Pod startet gar nicht erst, wenn der Migrate fehlschlägt. Das Boot-Gate innerhalb der App ist das zweite Netz (SchemaDriftError, wenn das Schema trotzdem vom Journal abweicht).
Backups
Der operators-Stack bringt Velero (K8s-Resources + PVC-Snapshots) und CloudNativePGs barman-cloud (kontinuierliches WAL-Archivieren) mit. Beides sichert nach Hetzner Object Storage (S3-kompatibel).
Daily K8s-Backup um 03:00 UTC, 7-Tage-Retention. Postgres-Backups sind kontinuierlich mit 30-Tage-Retention.
Multi-Tenant auf einem einzigen Postgres-Cluster
CloudNativePG erzeugt einen Postgres-Cluster (pg). Jede App bekommt darin ihre eigene Datenbank — Tenant-Provisioning legt Datenbanken on-demand an. Siehe Architecture: Tenant-DB-Context für das Per-Tenant-Connection-Routing.