From cc8d48698f8fb868ad737c4f40a4df6d6d675665 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 27 Jul 2026 13:44:34 +0200 Subject: [PATCH] Feat: Prod-Bootstrap (Erst-Superadmin) + Contabo-Runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/bootstrap-admin.ts: legt ersten Plattform-Admin + Mandant via provisionTenant(isPlatformAdmin) an; idempotent, per Env gesteuert. Ersetzt in Prod den Demo-Seed. tsx-@/-Auflösung lokal verifiziert. - docker-compose.coolify.yml: migrate-Job führt bei BOOTSTRAP_ADMIN=true das Skript nach der Migration aus; BOOTSTRAP_*-Vars durchgereicht. - .env.prod.example: Prod-Env-Referenz (HTTPS, kein Demo-Seed, Bootstrap-Vars). - docs/DEPLOY-PROD-CONTABO.md: Runbook (VPS-Härtung/Swap, Coolify+Gitea, DNS/TLS app.certvia.de, Deploy, Bootstrap, Backups, Go-Live-Checkliste). Co-Authored-By: Claude Opus 4.8 --- .env.prod.example | 52 ++++++++++++++++++++ .gitignore | 1 + docker-compose.coolify.yml | 11 ++++- docs/DEPLOY-PROD-CONTABO.md | 96 +++++++++++++++++++++++++++++++++++++ scripts/bootstrap-admin.ts | 80 +++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 .env.prod.example create mode 100644 docs/DEPLOY-PROD-CONTABO.md create mode 100644 scripts/bootstrap-admin.ts diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..cf14606 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,52 @@ +# Referenz für die PRODUKTIV-Env-Variablen (Contabo-VPS + Coolify). +# ECHTE Secrets NUR in Coolify eintragen — diese Datei enthält nur Platzhalter. +# Unterschiede zum Testserver: HTTPS-AUTH_URL, KEIN Demo-Seed, stattdessen Bootstrap-Admin. + +# --- Datenbank (Service "postgres") --- +POSTGRES_USER=isms +POSTGRES_PASSWORD=CHANGE_ME_starkes_db_passwort +POSTGRES_DB=isms +DATABASE_URL=postgresql://isms:CHANGE_ME_starkes_db_passwort@postgres:5432/isms?schema=public + +# --- Redis --- +REDIS_URL=redis://redis:6379 + +# --- Objektspeicher (MinIO) — S3_* muss zu MINIO_ROOT_* passen --- +S3_ENDPOINT=http://minio:9000 +S3_ACCESS_KEY=isms +S3_SECRET_KEY=CHANGE_ME_starkes_minio_passwort +S3_BUCKET=isms-documents +MINIO_ROOT_USER=isms +MINIO_ROOT_PASSWORD=CHANGE_ME_starkes_minio_passwort + +# --- Auth (NextAuth) — Produktiv über HTTPS --- +# AUTH_SECRET: openssl rand -base64 32 (frisch, NICHT der Testwert) +AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32 +AUTH_URL=https://app.certvia.de +# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) — nicht nötig. + +# --- KI-Provider (optional) --- +AI_PROVIDER=anthropic +AI_API_KEY= + +# --- E-Mail (produktives SMTP-Relay, sobald Einladungs-/Mailflow aktiv) --- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@certvia.de + +# --- Demo-Seed: in PROD AUS lassen! --- +RUN_DEMO_SEED=false + +# --- Erst-Superadmin-Bootstrap (statt Demo-Seed) --- +# Beim ersten Deploy true setzen -> migrate-Job legt Admin + Mandant an (idempotent). +# Danach kann true bleiben (tut nichts, wenn der Admin existiert) oder auf false. +BOOTSTRAP_ADMIN=true +BOOTSTRAP_ADMIN_EMAIL=admin@certvia.de +BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_initiales_admin_passwort +BOOTSTRAP_ADMIN_NAME=Certvia Admin +BOOTSTRAP_TENANT_NAME=Certvia +BOOTSTRAP_TENANT_SLUG=certvia +BOOTSTRAP_TENANT_SHORT=Certvia +BOOTSTRAP_TENANT_SECTOR= diff --git a/.gitignore b/.gitignore index 330ceed..a8e9be0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* .env* !.env.example !.env.coolify.example +!.env.prod.example # vercel .vercel diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 75ceb8f..dc9da68 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -14,10 +14,19 @@ services: build: context: . target: builder - command: sh -c "npx prisma migrate deploy && if [ \"$$RUN_DEMO_SEED\" = \"true\" ]; then echo '>> Demo-Seed läuft…'; npx tsx prisma/seed.ts; else echo '>> Demo-Seed übersprungen (RUN_DEMO_SEED != true)'; fi" + command: sh -c "npx prisma migrate deploy && if [ \"$$RUN_DEMO_SEED\" = \"true\" ]; then echo '>> Demo-Seed läuft…'; npx tsx prisma/seed.ts; fi && if [ \"$$BOOTSTRAP_ADMIN\" = \"true\" ]; then echo '>> Bootstrap-Admin läuft…'; npx tsx scripts/bootstrap-admin.ts; fi" environment: DATABASE_URL: ${DATABASE_URL} + # Test: Demo-Seed. Produktiv: stattdessen BOOTSTRAP_ADMIN (Erst-Superadmin). RUN_DEMO_SEED: ${RUN_DEMO_SEED:-false} + BOOTSTRAP_ADMIN: ${BOOTSTRAP_ADMIN:-false} + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} + BOOTSTRAP_ADMIN_NAME: ${BOOTSTRAP_ADMIN_NAME:-} + BOOTSTRAP_TENANT_NAME: ${BOOTSTRAP_TENANT_NAME:-} + BOOTSTRAP_TENANT_SLUG: ${BOOTSTRAP_TENANT_SLUG:-} + BOOTSTRAP_TENANT_SHORT: ${BOOTSTRAP_TENANT_SHORT:-} + BOOTSTRAP_TENANT_SECTOR: ${BOOTSTRAP_TENANT_SECTOR:-} depends_on: postgres: condition: service_healthy diff --git a/docs/DEPLOY-PROD-CONTABO.md b/docs/DEPLOY-PROD-CONTABO.md new file mode 100644 index 0000000..ffc53fa --- /dev/null +++ b/docs/DEPLOY-PROD-CONTABO.md @@ -0,0 +1,96 @@ +# Deployment: Produktivserver (Contabo-VPS via Coolify) + +> Zielumgebung: öffentlicher Contabo-VPS, eigene Coolify-Instanz, Git (Gitea) auf +> demselben VPS, Domain **app.certvia.de** mit echtem Let's-Encrypt-TLS. +> Ergänzt `docs/DEPLOY-COOLIFY.md` (Testserver). Deploy-Datei: `docker-compose.coolify.yml`. + +## Unterschiede zum Testserver + +| Aspekt | Test (intern) | Produktiv (Contabo) | +|--------|---------------|---------------------| +| Erreichbarkeit | intern, HTTP, sslip.io | öffentlich, **HTTPS**, `app.certvia.de` | +| Git-Quelle | internes Gitea | **Gitea auf dem VPS** (Release-Push) | +| Erst-Admin | Demo-Seed (`RUN_DEMO_SEED=true`) | **Bootstrap-Admin** (`BOOTSTRAP_ADMIN=true`), **kein** Demo-Seed | +| Secrets | Testwerte | **frische, starke** Secrets | +| Backups | optional | **Pflicht** (pg_dump + Restore-Test) | +| Branch | `dev` | `main` | + +--- + +## Phase 1 — VPS-Grundlage + +1. **Contabo-VPS** bereitstellen — **≥ 8 GB RAM** empfohlen (Next-Build ~1 GB + Stack). Bei weniger RAM zwingend Swap: + ```bash + fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile + echo '/swapfile none swap sw 0 0' >> /etc/fstab + free -h + ``` +2. **Grundhärtung**: System aktualisieren, SSH-Key-Login (Passwort-Login aus), unattended-upgrades. +3. **Firewall** (ufw): nur benötigte Ports offen — `80`, `443`, SSH, sowie der Gitea-SSH-Port (s. Phase 2). +4. **Coolify installieren** (offizielles Installskript) — eigene, unabhängige Instanz. + +## Phase 2 — Gitea auf dem VPS + +5. Gitea als **Coolify-Ressource** (One-Click/Compose) aufsetzen, eigene Subdomain (z. B. `git.certvia.de`), TLS via Coolify. +6. Repo `ISMS-Tool` in diesem Gitea anlegen. +7. Lokal ein **zweites Remote** hinzufügen und beim Release `main` dorthin pushen: + ```bash + git remote add prod https://git.certvia.de//ISMS-Tool.git + git push prod main + ``` + (Das ist der bewusste „Release-Push" — Prod deployt nur, was hier landet.) + +## Phase 3 — DNS & Domain + +8. Bei eurem DNS für `certvia.de` einen **A-Record** `app.certvia.de` → Contabo-IP anlegen (ebenso `git.certvia.de`). +9. Coolify vergibt/prüft danach automatisch das Let's-Encrypt-Zertifikat (VPS ist öffentlich erreichbar). + +## Phase 4 — App-Ressource (Prod) in Coolify + +10. **+ New Resource** → Git-Quelle = das **VPS-Gitea** (Deploy-Key), Repo `ISMS-Tool`, **Branch `main`**. +11. **Build Pack: Docker Compose**, **Compose Location: `docker-compose.coolify.yml`**. +12. **Domain** beim Service `app` = `https://app.certvia.de:3000` (Port 3000), Schema **https**. +13. **Environment-Variablen** aus `.env.prod.example` setzen (Runtime-Variablen). Kritisch: + - Alle Secrets **frisch** (`AUTH_SECRET` neu: `openssl rand -base64 32`; neue DB-/MinIO-Passwörter). + - `AUTH_URL=https://app.certvia.de` (exakt die App-Domain). + - `RUN_DEMO_SEED=false` (bzw. weglassen). + - **Bootstrap** (erster Deploy): `BOOTSTRAP_ADMIN=true` + `BOOTSTRAP_ADMIN_EMAIL/PASSWORD/NAME` + `BOOTSTRAP_TENANT_NAME/SLUG`. +14. **Persistent Storage** prüfen — v. a. `pgdata` (sonst Datenverlust bei Redeploy). + +## Phase 5 — Erster Deploy & Bootstrap + +15. **Deploy**. Ablauf: `postgres` (healthy) → `migrate` (migriert **und** legt via `BOOTSTRAP_ADMIN=true` den Erst-Admin an) → `app`. +16. In den **`migrate`-Logs** prüfen: `>> Bootstrap-Admin läuft…` → `✔ Bootstrap fertig: Mandant … + Plattform-Admin … angelegt.` +17. **Login** unter `https://app.certvia.de` mit den `BOOTSTRAP_ADMIN_*`-Zugangsdaten → **Passwort sofort ändern**. +18. Optional danach `BOOTSTRAP_ADMIN` auf `false` (das Skript ist idempotent, es schadet aber nicht, es an zu lassen). + +## Phase 6 — Backups & Betrieb (vor „echtem" Go-Live) + +19. **Postgres-Backups**: regelmäßiger `pg_dump` (oder Coolify-DB-Backup nach S3) + **Restore-Test** dokumentieren. `pgdata` ist das kritische Volume. +20. **MinIO-Backup**, sobald Datei-/Logo-Upload aktiv ist. +21. **Monitoring/Alerting**: App-Healthcheck ist im Compose vorhanden; zusätzlich Uptime-Check auf `https://app.certvia.de` und Log-Aggregation einrichten. +22. **Updates**: Betriebssystem/Coolify/Images regelmäßig aktualisieren. + +## Phase 7 — Auto-Deploy (optional) + +23. Gitea-Webhook (VPS-Gitea → Coolify) mit **Branch-Filter `main`**. Da Gitea und Coolify auf demselben Host sind, ggf. `GITEA__webhook__ALLOWED_HOST_LIST` weit genug setzen (vgl. Testserver-Erfahrung). + +--- + +## Release-Fluss (Zusammenfassung) + +``` +Feature-Branch → dev # Entwicklung, Auto-Deploy Testserver +dev → main # Release-Merge (nach Test-Freigabe) +git push prod main # Release-Push ins VPS-Gitea → Prod-Deploy +``` + +## Sicherheits-Checkliste Go-Live + +- [ ] Frische, starke Secrets (nicht aus Test übernommen) +- [ ] `RUN_DEMO_SEED` aus, **keine** Demo-Daten in Prod +- [ ] Bootstrap-Admin-Passwort nach erstem Login geändert +- [ ] HTTPS erzwungen, gültiges Zertifikat +- [ ] `pgdata` persistent + Backup + Restore-Test +- [ ] Firewall aktiv, SSH gehärtet +- [ ] Gitea-Repo privat, Zugriff nur für das Team diff --git a/scripts/bootstrap-admin.ts b/scripts/bootstrap-admin.ts new file mode 100644 index 0000000..d6118ec --- /dev/null +++ b/scripts/bootstrap-admin.ts @@ -0,0 +1,80 @@ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { provisionTenant } from "@/server/provision"; + +/** + * Erst-Bootstrap für Produktiv: legt den ersten Plattform-Admin + einen Mandanten an + * — als Ersatz für den Demo-Seed (der NUR für Test/Dev ist). Ohne diesen Schritt gibt + * es in Prod keinen Benutzer und niemand kann sich anmelden. + * + * Ausführung: im migrate-Job nach `prisma migrate deploy`, gesteuert per BOOTSTRAP_ADMIN=true + * (siehe docker-compose.coolify.yml und docs/DEPLOY-PROD-CONTABO.md). + * + * Idempotent: existiert bereits ein Plattform-Admin mit der E-Mail, passiert nichts + * (kein Passwort-Reset, keine Duplikate). provisionTenant selbst arbeitet mit upserts. + * + * Erforderliche Umgebungsvariablen: + * BOOTSTRAP_ADMIN_EMAIL E-Mail des ersten Plattform-Admins + * BOOTSTRAP_ADMIN_PASSWORD Initiales Passwort (nach erstem Login ändern) + * BOOTSTRAP_ADMIN_NAME Anzeigename + * BOOTSTRAP_TENANT_NAME Name des ersten Mandanten + * BOOTSTRAP_TENANT_SLUG URL-Slug des Mandanten (klein, eindeutig) + * Optional: + * BOOTSTRAP_TENANT_SHORT Kürzel des Mandanten + * BOOTSTRAP_TENANT_SECTOR Branche + */ + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), +}); + +function req(name: string): string { + const v = process.env[name]; + if (!v || v.trim() === "") { + console.error(`✖ Bootstrap abgebrochen: Umgebungsvariable ${name} fehlt.`); + process.exit(1); + } + return v.trim(); +} + +async function main() { + const email = req("BOOTSTRAP_ADMIN_EMAIL").toLowerCase(); + const password = req("BOOTSTRAP_ADMIN_PASSWORD"); + const name = req("BOOTSTRAP_ADMIN_NAME"); + const tenantName = req("BOOTSTRAP_TENANT_NAME"); + const tenantSlug = req("BOOTSTRAP_TENANT_SLUG").toLowerCase(); + const short = process.env.BOOTSTRAP_TENANT_SHORT?.trim() || undefined; + const sector = process.env.BOOTSTRAP_TENANT_SECTOR?.trim() || undefined; + + // Idempotenz: existiert der Plattform-Admin schon, nichts tun (kein PW-Reset). + const existing = await prisma.user.findFirst({ + where: { email, isPlatformAdmin: true }, + select: { id: true }, + }); + if (existing) { + console.log(`✔ Plattform-Admin ${email} existiert bereits — Bootstrap übersprungen.`); + return; + } + + const tenant = await provisionTenant(prisma, { + name: tenantName, + slug: tenantSlug, + short, + sector, + admin: { email, name, password, isPlatformAdmin: true }, + }); + + console.log( + `✔ Bootstrap fertig: Mandant "${tenant.name}" (${tenantSlug}) + Plattform-Admin ${email} angelegt.` + ); + console.log(" → Bitte nach dem ersten Login das Passwort ändern."); +} + +main() + .then(() => prisma.$disconnect()) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + });