Skip to content

Prod Cutover Checklist

Offene Aufgaben, die beim Schritt von Staging auf den produktiven web.targetshot.app-Deploy mit erledigt werden müssen. Neue Items bitte hier unten anhängen, abgeschlossene abhaken statt zu löschen — dient als Audit-Spur.

Azure Blob Storage: Prod-Cleanup-Workflow

Erledigt am 2026-09-01 — aber anders als unten geplant: Prod läuft als App Service (kein Compose, kein self-hosted Runner), deshalb startet cleanup-orphan-photos-prod.yml einen Einmal-Container (ACI) in der RG targetshot-jobs mit dem aktuell deployten Backend-Image und führt das Skript dort aus. Secrets kommen zur Laufzeit aus kc-kv-prd01 (gh-oidc-SP ist Key Vault Secrets User; Contributor nur auf targetshot-jobs), die DB ist über die bestehende „Allow Azure services"-Firewallregel erreichbar.

  • [x] Workflow cleanup-orphan-photos-prod.yml angelegt (ACI-Variante, siehe oben — die ursprünglich geplanten Punkte Prod-Runner, PROD_DOCKER_COMPOSE_DIR und Secret-Injektion in den Deploy entfallen damit ersatzlos).
  • [x] Einmaliger Dry-Run am 2026-09-01 (per identischem ACI-Rezept): 2 Blobs insgesamt, 0 Orphans — Prod ist sauber; der erste echte Durchlauf lohnt erst nach ein paar Foto-Updates.
  • [x] Keine Shared-Container-Gefahr: eigene Container je Umgebung (photos-staging vs. photos-prod); der Cleanup diff't nur gegen die DB seines DATABASE_URL. Zusätzlich gehärtet am 2026-09-01: das Skript zieht die Blob-Liste vor dem DB-Lesen (Wächter-Test backend/test/cleanupOrphanPhotosOrder.test.ts), damit ein Upload während des Laufs nicht als Waise gilt.

Keycloak: Prod-Deployment nach Azure

Design-Dokument: Keycloak auf Azure. Staging bleibt unverändert on-prem hinter HAProxy; der Cutover betrifft nur auth.targetshot.app + auth-admin.targetshot.app.

Infrastruktur

Status 2026-04-26 (Sprint 19 #104): Provisioniert via infrastructure/keycloak/main.bicep + deploy.sh. Endpunkt: https://keycloak-prod-prd01.azurewebsites.net (Default-Hostname, Custom Domain folgt unten). Liquibase-Migration lief beim ersten Container-Boot durch — /realms/master antwortet 200. Abweichungen ggü. ursprünglichem Design sind unten markiert (*Δ*).

  • [x] Resource Group targetshot-kc (Germany West Central).
  • [x] VNet targetshot-kc-vnet (10.20.0.0/16) mit Subnet app (10.20.1.0/24), delegated zu Microsoft.Web/serverFarms.
  • [x] Δ db-Subnet entfällt — Postgres läuft mit **Public Endpoint
  • Firewall-Regel AllowAllAzureServicesAndResourcesWithinAzureIps** statt private VNet-Integration. Grund: LocationIsOfferRestricted für Flex-Server in Germany West Central in unserer Subscription. Postgres läuft daher in westeurope (übriges Stack bleibt in Germany West Central). SSL ist erzwungen, Whitelist begrenzt Zugriff auf Azure-IPs.
  • [x] Δ Private DNS Zone entfällt (folgt aus dem Punkt darüber).
  • [x] Azure Container Registry Basic (targetshotkcprd01).
  • [x] Δ Azure DB for PostgreSQL Flex Standard_B1ms in westeurope, Server-Name targetshot-kc-pg-prd01-we, Admin-User pgadmin, 32 GB SSD, 7-Tage PITR. Public Access (s. o.).
  • [x] Δ App Service Plan Linux P0v3 (PremiumV3) statt B2 — unsere Subscription hatte für die Basic-Familie Limit=0; Quota via Microsoft.Quota für P0v3 gehoben. Kostenlich vergleichbar.
  • [x] Web App keycloak-prod-prd01, Container aus ACR, System- Assigned Managed Identity zugeordnet (AcrPull + KV-Secret-User).
  • [x] Regional VNet Integration der Web App auf Subnet app.
  • [x] Key Vault kc-kv-prd01 für DB-Passwort, Web App holt KC_DB_PASSWORD via @Microsoft.KeyVault(...) Reference (Status per az rest /configreferences: Resolved).

Datenbank-Bootstrap

  • [x] keycloak-Datenbank wird durch Bicep (Microsoft.DBforPostgreSQL/flexibleServers/databases) angelegt. Schema-Migration übernimmt Liquibase beim ersten Keycloak-Start; manuelles CREATE USER/GRANT entfällt, weil Keycloak mit dem Server-Admin (pgadmin) connected.
  • [x] Passwort in Key Vault unter kc-db-password (vom deploy.sh beim ersten Lauf generiert; Re-Runs lesen es zurück).

Keycloak-Image

  • [x] infrastructure/keycloak/Dockerfile (Keycloak 26.0.7 LTS, Themes baked-in, kc.sh build im Builder-Stage, start --optimized als CMD).
  • [x] Workflow infrastructure/.github/workflows/build-keycloak-image.yml baut + pusht bei Push auf keycloak/** nach targetshotkcprd01.azurecr.io/keycloak:<sha>. Pre-conds: Federated Credential für repo:targetshot-app/infrastructure: ref:refs/heads/main + AcrPush-Rolle auf der ACR sind angelegt.
  • [ ] Optionaler deploy-keycloak-prod.yml (rollt neue Tags automatisch via az webapp config container set aus). Aktuell manuell — passt für die niedrige Release-Frequenz.

Domains

  • [x] Cloudflare DNS: CNAME authkeycloak-prod-prd01.azurewebsites.net, DNS only (graue Wolke).
  • [x] Cloudflare DNS: CNAME auth-admin → gleicher Target, DNS only.
  • [x] Azure App Service Custom Domain auth.targetshot.app gebunden (CNAME-Verifikation reichte, kein TXT nötig). Managed Certificate via az webapp config ssl create + SNI-Binding aktiv (GeoTrust, läuft bis 2026-10-26).
  • [x] Gleiche Prozedur für auth-admin.targetshot.app.
  • [x] KC_HOSTNAME auf https://auth.targetshot.app umgestellt (Keycloak baut Cookies + OIDC-URLs jetzt mit dem Custom-Hostname).

Admin-Console-Härtung (Host-Split + 2FA)

Status 2026-04-26: Microsoft hat Path-based App-Service- Restrictions entfernt — weder ARM/Bicep/REST (pathPattern nicht im Schema) noch das Portal (kein Path-Feld im "Add rule"-Dialog) bieten das mehr an. Statt IP-Allowlist auf /admin/* setzen wir auf zwei billigere Schichten:

  • [x] Host-Split via KC_HOSTNAME_ADMIN: Keycloak 26 rendert alle Admin-Console-URLs (Cookies, OIDC-Redirects, interne Links) gegen auth-admin.targetshot.app. Der /admin/*-Endpoint ANTWORTET zwar weiterhin auch auf auth.targetshot.app mit HTTP 200 (KC erzwingt kein Host-Allowlisting per Pfad), aber Login funktioniert praktisch nur über den Admin-Hostname weil Cookies + Redirects dahin gehen. Im Bicep gesetzt + per App Setting live.
  • [ ] 2FA auf den echten Admin-User: Beim ersten Login als admin einen User mit deiner Mail-Adresse anlegen, der Rolle admin (Realm-Role im master-Realm) bekommt. Required Action „Configure OTP" für den User aktivieren, ausloggen, neu einloggen → TOTP-App (Authy/1Password) registrieren. Erst dann den Bootstrap-admin löschen.
  • [ ] Brute-Force-Schutz: master-Realm → Realm settings → Security defenses → Brute force detection → Enabled (Standard: 30 Fails / 15 min Lockout). Reicht gegen Wörterbuch-Attacken.

Gegen einen gezielten Angreifer, der den Admin-Hostnamen kennt UND das 2FA-Secret hat, hilft das nicht — dafür bräuchte es Front Door davor mit Path-Routing + WAF (€€/Monat). Aktuell zu teuer für den Threat-Level.

Realm-Migration

  • [ ] Bestehende Realms auf dem on-prem Keycloak exportieren (kc.sh export --dir /tmp/export --users realm_file --realm <realm>).
  • [ ] Export-Files ins Image kopieren oder via KC_IMPORT_REALM=/opt/keycloak/data/import beim ersten Start einspielen.
  • [ ] Separates Runbook für User-Migration schreiben (Postgres-Dump
  • Restore) — nur wenn Realm-Level-User-Export nicht ausreicht.

HAProxy-Anpassung (bestehender on-prem Edge)

  • [ ] In infrastructure/haproxy/haproxy.cfg kc_backend stilllegen oder auf Staging-IP umbiegen. Prod-Auth geht komplett über die Azure-Custom-Domains, nicht mehr über HAProxy.
  • [ ] Host-ACLs für auth.targetshot.app + auth-admin.targetshot.app aus der cf_protected_host-Liste entfernen (beide sind bewusst nicht hinter CF-Proxy — DNS-only).

Rollback-Plan

  • [ ] TTL der neuen CNAMEs vorab auf 5 Minuten setzen, damit ein Rollback via DNS-Switch auf das on-prem Keycloak innerhalb einer Viertelstunde durchschlägt.
  • [ ] Staging-Keycloak bleibt aktiv — dient als Fallback-IdP mit identischem Realm-Export, falls die Azure-Instanz sich nicht bootet.

Ingest: Prod-Migration auf Azure Functions + Postgres Flexible Server

Design-Dokument: Ingest auf Azure Functions + Postgres Flex. Staging bleibt auf dem bestehenden Compose-Ingest (ts-admin-portal); der Cutover betrifft nur den produktiven Schreibpfad.

**Status 2026-09-01 (nachgeführt; live seit 2026-04-30, Sprint 19

110 / Phase 5):** Der Prod-Ingest läuft produktiv auf Azure

Functions — Function-App targetshot-ingest-prd01 (RG targetshot-kc, geteilter P0v3-Plan keycloak-prod-plan) mit vier EventHub-Trigger-Functions, Consumer-Group targetshot-prod-ingest, DB targetshot auf dem geteilten Flex Server targetshot-kc-pg-prd01-we. Metriken gesund (Stichprobe 2026-08-29: 16 620 Treffer-Events ohne Lag). Abweichungen ggü. dem Plan unten als *Δ* markiert (per az verifiziert 2026-09-01). Echter Rest: Deploy-Workflow, Managed-Identity-Auth zur Flex-DB, Lag-Dashboard-Frage, Phase 6 Multi-Host (s. u.).

Infrastruktur

  • [x] Δ Eigene Resource Group targetshot-prod-ingest entfällt — die Function-App läuft in der bestehenden RG targetshot-kc neben Keycloak (ein gemeinsamer Prod-Stack statt zweiter Verwaltungsfläche).
  • [x] Δ Kein dedizierter Flex Server Standard_B2s — der Ingest nutzt den bestehenden Keycloak-Flex-Server targetshot-kc-pg-prd01-we (Standard_B1ms, westeurope, 7-Tage-PITR, Public Access + „Allow Azure services", s. Keycloak-Abschnitt) mit eigener Datenbank targetshot.
  • [ ] Entra-Admin auf dem Flex Server setzen (az postgres flexible-server ad-admin create), damit Managed Identity-Auth möglich bleibt. Stand 2026-09-01: offen — hängt am Managed-Identity-Umstieg (s. Function-App-Identity unten).
  • [x] Δ Storage Account heißt tsingestprd01 (Standard LRS, RG targetshot-kc) statt targetshotprodfunc<rand>.
  • [x] Δ Function App heißt targetshot-ingest-prd01 und läuft auf dem geteilten, bereits vorhandenen P0v3-Plan (keycloak-prod-plan) statt Linux Consumption — kein Cold-Start, keine Zusatzkosten für einen zweiten Plan. Runtime Node 20 statt geplantem Node 22. System-Assigned Managed Identity ist aktiviert.
  • [ ] Function-App-Identity als Postgres-Role (GRANT CONNECT ... ; GRANT SELECT/INSERT/UPDATE/DELETE ... ;) in der Flex-DB anlegen. Stand 2026-09-01: offen — die Function connectet per Passwort-User (App-Settings TARGETSHOT_DATABASE_*); MI-Auth zusammen mit dem Entra-Admin-Punkt oben nachziehen.

Event Hubs

  • [x] Consumer-Group targetshot-prod-ingest auf allen 4 prod.sds.*-Hubs per az eventhubs eventhub consumer-group create ... anlegen bevor die Function erstmalig startet. Sonst stiller Retry wie beim Staging-Cutover 2026-04-24. (Auf allen 4 Hubs vorhanden, per az verifiziert 2026-09-01.)
  • [x] Δ SAS-Policy prod-listen (Listen only) existiert auf Namespace-Ebene (targetshot-eh), nicht je Hub; das Function-App-Setting heißt EVENTHUB_LISTEN_CS statt EVENTHUBS_CONNECTION (Code-Default in packages/functions-ingest/src/index.ts).

Schema

  • [x] npx prisma migrate deploy einmalig gegen den frischen Flex Server ausführen (von einer Dev-IP, die in der Firewall steht). (Erledigt zum Go-Live 2026-04-30 — Ziel-DB ist targetshot auf dem geteilten Flex Server, Schema nimmt seither Events an.)
  • [x] Prisma Client ins Function-App-Deploy bundlen. (Das Deploy-Skript packages/functions-ingest/scripts/package-for-deploy.sh kopiert src/generated/prisma-client/ samt Engine-Binaries in die app.zip.)
  • [x] Keine Daten-Migration aus Staging — Prod startet leer und wird über Debezium-Snapshots der ersten produktiv ausgerollten Clubs befüllt. (So passiert: erster Verein per Debezium-Snapshot, seither laufender Event-Strom.)

Function-App-Code

  • [x] Δ Code liegt als Workspace-Package packages/functions-ingest/ (nicht targetshot/ingest-functions/); die vier Trigger heißen IngestSchuetze, IngestTreffer, IngestScheiben, IngestSerien.
  • [x] Δ Statt Portierung: die Upsert-/Handler-Logik wurde ins geteilte Package packages/ingest-core extrahiert — backend/src/ingest/ (Staging-Pfad) und die Function-App rufen denselben processMessage-Code auf, Prod- und Staging-Ingest bleiben damit deckungsgleich.
  • [x] Δ host.json ist über extensions.eventHubs getuned (maxEventBatchSize: 64, prefetchCount: 256, batchCheckpointFrequency: 1) — die geplanten Keys batchSize/maxConcurrency heißen im v4-Programmiermodell anders.
  • [ ] GitHub Action deploy-prod-ingest-functions.yml mit Azure/functions-action@v1 + OIDC-Login einrichten. Stand 2026-09-01: offen — Deploy läuft manuell als Zip (packages/functions-ingest/scripts/package-for-deploy.shaz functionapp deployment source config-zip).

Verifikation nach Cutover

  • [x] Δ Test-Message-Ritual ist durch den Live-Betrieb überholt: seit 2026-04-30 fließen echte prod.sds.*-Events durch die Function in die Flex-DB — der Ende-zu-Ende-Schreibpfad ist damit laufend belegt.
  • [x] IncomingMessages + OutgoingMessages auf EH für die Prod-Topics monitoren; Function-App Logs auf Unauthorized/InvalidCredentials checken. (Metrik-Stichprobe 2026-08-29: 16 620 Treffer-Events ohne Lag, keine Auth-Fehler.)
  • [ ] Ingest-Lag-Dashboard (kommt mit App-Insights) zeigt < 30 s. Stand 2026-09-01: offen als Grundsatzfrage — die Lag-Sicht lebt im admin-portal (Cloud-Ingest-Observability), nicht in App Insights. Entscheiden, ob ein App-Insights-Dashboard zusätzlich überhaupt gebraucht wird.

Rollback

  • [x] Function App per Portal auf „Stopped" — EH puffert bis 7 Tage, nichts geht verloren. (Mechanismus steht: EH-Retention 168 h, per az verifiziert 2026-09-01.)
  • [x] Flex Server per PITR auf T-5 min zurück (bei DB-Korruption). (7-Tage-PITR am geteilten Flex Server konfiguriert, s. Keycloak-Abschnitt.)
  • [x] Δ „Vor echtem Prod-Traffic: System ist beliebig oft wegwerfbar" gilt nicht mehr — seit 2026-04-30 liegen echte Prod-Daten in der DB. Rollback heißt jetzt Function stoppen + PITR, Wegwerfen ist keine Option mehr.

Offen nach Go-Live (Stand 2026-09-01)

  • [ ] Phase 6 Multi-Host: Rollout eines zweiten Vereins-Hosts — bewusst geblockt, bis der zweite Verein konkret ansteht.