Feat: Prod-Bootstrap (Erst-Superadmin) + Contabo-Runbook
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||||
@@ -34,6 +34,7 @@ yarn-error.log*
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.coolify.example
|
!.env.coolify.example
|
||||||
|
!.env.prod.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -14,10 +14,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: builder
|
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:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
# Test: Demo-Seed. Produktiv: stattdessen BOOTSTRAP_ADMIN (Erst-Superadmin).
|
||||||
RUN_DEMO_SEED: ${RUN_DEMO_SEED:-false}
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -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/<org>/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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user