# Deployment

> Letzte Aktualisierung: 2026-06-24
> Status: ✅ CD v4 (Auto inside CI + Manual) + Owner-Passwort-Persistenz (SeedAudit)
> Live-URL: https://nexus.noveria.net

## CD-Philosophie (v4)

- **CI läuft automatisch** bei jedem Push → darf nie brechen
- **CD auto + manuell**: Automatischer Deploy als abschließender CI-Job auf main; manueller Deploy via `workflow_dispatch`
- **Loop-Schutz**: Deploy mutiert Git nicht und erzeugt deshalb keine Deploy-Schleifen
- Deploy liest und validiert `VERSION`, mutiert aber weder Git noch Tags
- **Rollback** als eigener Workflow, manuell triggerbar
- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule)

## Workflows

### Auto-Deploy (`.gitea/workflows/ci.yaml`)

**Trigger**:
- **Automatisch**: Der Job `Deploy Nexus` läuft nach `backend`, `frontend` und `security`.
- Bedingung: Push auf `main`, alle CI-Jobs grün.
- Grund: Gitea 1.26.3 hat für grüne CI-Runs 303/304 keinen `workflow_run`-Deploy erzeugt; der zuverlässigste Ersatz ist ein `needs`-gesteuerter Job im selben Workflow.

### Manual Deploy (`.gitea/workflows/deploy.yaml`)

**Trigger**:
- **Manuell**: Via Gitea Actions → `workflow_dispatch`

**Loop-Schutz**:
- Deploy erstellt keine Commits, Tags oder Version-Bumps. Dadurch entsteht keine CI/CD-Schleife.

**Inputs**: keine. Der manuelle Deploy nutzt denselben Main-Deploy-Pfad wie der Auto-Deploy.

**Ablauf**:
1. Checkout von `main` (beim manuellen Deploy) oder Nutzung des geprüften CI-Checkouts (Auto-Deploy)
2. `.gitea/scripts/deploy-nexus.sh`
3. `VERSION` lesen und SemVer validieren
4. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
5. Code-Sync zum Host-Deploy-Pfad
6. `docker compose build && up -d --force-recreate --remove-orphans --wait`
7. `.env`-Tempfile wird mit `shred` gelöscht
8. Health-Check und Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)

### Backup (`.gitea/workflows/backup.yaml`)

**Trigger**: Manuell via Gitea Actions → `workflow_dispatch` (optional: Nightly-Schedule via Cron)

**Inputs**:
| Input | Typ | Default | Beschreibung |
|---|---|---|---|
| `keep_on_host` | boolean | false | Backup auch auf Host-Pfad kopieren |
| `host_backup_path` | string | `/opt/openclaw/backups` | Host-Zielpfad |

**Ablauf**:
1. Backup-ID generieren (Timestamp-basiert)
2. `docker exec nexus-postgres-1 pg_dumpall -U nexus` → gzip
3. Upload als Gitea-Artifact (90 Tage Retention, bereits komprimiert)
4. Optional: Kopie auf Host-Pfad via Docker-Volume-Mount
5. Integritäts-Check: gzip-Test + SQL-Header-Validierung
6. Backup-Summary mit Restore-Befehl

**Restore (manuell auf dem Host)**:
```bash
# Aus Gitea-Artifact herunterladen oder von Host-Pfad:
zcat nexus-backup-YYYY-MM-DDTHHMMSSZ.sql.gz | docker exec -i nexus-postgres-1 psql -U nexus -d postgres
# Danach Stack neu starten:
cd /opt/openclaw/data/openclaw/workspace/nexus
docker compose up -d --wait
```

**Nightly-Schedule aktivieren**:
In `backup.yaml` die Zeilen auskommentieren:
```yaml
schedule:
  - cron: '0 3 * * *'  # Jede Nacht um 03:00 UTC
```

### Rollback (`.gitea/workflows/rollback.yaml`)

**Trigger**: Manuell via Gitea Actions → `workflow_dispatch`

**Concurrency**: Rollback nutzt dieselbe `deploy-production`-Gruppe wie Deploy, aber mit `cancel-in-progress: true`. Dadurch gewinnt Rollback gegenüber laufenden oder wartenden Deploys und verhindert, dass ein Auto-Deploy direkt nach einem Rollback den Rollback wieder überschreibt.

**Inputs**:
| Input | Typ | Beschreibung |
|---|---|---|
| `target_tag` | string | Git-Tag zum Zurückrollen (z.B. `v0.2.49`) |
| `confirm` | string | Muss exakt `ROLLBACK` sein (Safety-Gate) |

**Ablauf**:
1. Safety-Gate: Bestätigungstext muss `ROLLBACK` sein
2. Checkout des Target-Tags
3. Tag-Validierung (existiert? welcher Commit?)
4. Safe Secret Handling (gleiches Tempfile-Pattern)
5. Code-Sync des alten Stands zum Host
6. `docker compose build --no-cache && up -d --wait --force-recreate`
7. Health-Check + Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
8. Bei Fehler: Reviewer-Handoff mit manueller Rollback-Anleitung

**DB-Migration bei Rollback**: Die API führt `MigrateAsync` beim Start aus. Wenn die Migrationen des Rollback-Tags ein Prefix der aktuellen DB sind (Normalfall), läuft EF Core sie als No-Op. Wenn ein Rollback-Tag vor einer destruktiven Migration liegt, ist manuelles DB-Intervention nötig — ein Edge Case, der DevOps signalisiert wird.

## Secrets und Konfiguration

### Owner Password Persistence (2026-06-21, permanent fix)

**Root Cause**: Die fruehere Passwort-Injektion ueber Deploy-Runtime schuf einen unnötigen zweiten Pfad neben der DB und machte Passwort-Drift/Re-Seeding-Folgen möglich.

**Fix (3 Schichten)**:
1. **SeedAudit-Entity** (DB-Migration `20260621081500_AddSeedAudit`): `EnsureDatabaseAsync` prueft die `SeedAudit`-Tabelle auf Key `owner_created` VOR dem Seeden. Ist dieser Key vorhanden, wird der Owner NIE neu erstellt — selbst wenn die Users-Tabelle komplett geloescht wird.
2. **Single Source of Truth**: Deploy- und Rollback-Workflows injizieren gar kein `OWNER_PASSWORD` mehr. Nach dem ersten Seed ist ausschließlich die DB kanonisch.
3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`). Bootstrap läuft nur noch über `BOOTSTRAP_OWNER_EMAIL`.

**Verifikation (2026-06-21)**:
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
- Login funktioniert nach `docker compose up -d --force-recreate --wait`
- Login funktioniert nach `docker compose restart`
- SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup

**Regel gegen Wiederholung**: Kein `OWNER_PASSWORD` mehr in Deploy-Runtime, Host-`.env` oder Secrets pflegen. Passwort-Änderungen laufen nur noch über App/DB-Pfade.

### Secrets in Gitea

Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfiguriert:

| Secret | Verwendung |
|---|---|
| `ENV_POSTGRES_PASSWORD` | PostgreSQL-Passwort |
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |

> **Hinweis**: `ENV_OWNER_PASSWORD` bleibt entfernt. `OWNER_PASSWORD` wird auch nicht mehr aus Host-`.env` eingelesen.

### Safe Secret Handling (v3)

**Vorher (unsicher)**: Secrets wurden via `${{ secrets.X }}` direkt in eine Datei im Workspace interpoliert, die dann zum Host synct wurde. Das `.env` lag potenziell lesbar im Workspace und auf dem Host-Dateisystem.

**Jetzt (sicher)**:
1. Secrets werden als Step-Environment aus Gitea Secrets bezogen und erst dann in `/tmp/nexus-deploy-env` (mode 600) geschrieben
2. Die Temp-Datei wird via `docker run -v` als read-only ins Compose-Environment gemountet
3. Nach Deploy/Rollback wird die Datei mit `shred -u` gelöscht
4. Das `.env` erscheint **nie** im Workspace oder auf dem Host-Deploy-Pfad

## Build-Anleitung (lokal oder in CI)

Die folgenden Befehle sind auf dem Build-System auszuführen. Vor dem Build müssen die Secrets in `.env` gesetzt sein.

```bash
# 1. Backend veröffentlichen
cd backend
dotnet publish -c Release -o dist

# 2. Frontend bauen (pnpm preferred)
cd ../frontend
pnpm install
pnpm build
# └─ Output: frontend/dist/ (statisch auslieferbar)

# 3. Docker-Stack starten (wenn compose verwendet wird)
cd ..
docker compose up -d --build
```

Die Container holen sich ihre Umgebungsvariablen aus der `.env` im Projektstamm.  
Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.

## Deployment-Plan

1. `.env`-Datei auf dem VPS anlegen (alle Secrets generieren/setzen)
2. Backup vor produktiven Infrastrukturarbeiten
3. Docker-Stack auf dem VPS deployen
4. Datenbankmigration läuft automatisch beim Start (via `MigrateAsync`)
5. Traefik-Router fuer `nexus.noveria.net` per Compose-Labels verbinden
6. HTTPS, Header, Cookies und externe Erreichbarkeit validieren

## Abgeschlossene Deployment-Arbeit

- [x] Produktions-`.env` mit starken, getrennten Secrets angelegt (2026-06-08)
- [x] Datenbankmigration und kompletter Stack per Docker-Compose deployt
- [x] Nexus auf dem VPS deployt (Docker Compose)
- [x] Traefik mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert
- [x] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert
- [x] Externe Erreichbarkeit bestaetigt (2026-06-09)
- [x] CI/CD entkoppelt — Deploy darf automatisch (v3) oder manuell (2026-06-13)
- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13)
- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13)
- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13)
- [x] Deploy liest und validiert `VERSION`, mutiert aber keine Git-Tags oder Version-Dateien (2026-06-23)
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
- [x] Live-Recheck nach Deploy-Stoerung: `/health`, SPA-Root und `GET /api/dashboard/tasks` wieder 200; Bao-Folgetask zur Agent-Progress-Visibility erstellt (2026-06-20)
- [x] Agent-Progress-Stand (`2d21885`) manuell als sauberer Commit-Snapshot live ausgerollt, nachdem der normale Gitea-Deploy-Trigger blockierte (2026-06-20)

## Verifizierung

### 2026-06-20

- https://nexus.noveria.net/ → 200 OK, SPA geladen (`<title>Nexus | Noveria Operations</title>`)
- /health → 200 Healthy, PostgreSQL + Runtime healthy
- /api/dashboard/tasks → 200 OK mit `X-Nexus-Api-Key`
- Follow-up-Task `Restore agent progress visibility in Nexus` fuer `assignedTo=bao` erfolgreich angelegt

### 2026-06-09

- https://nexus.noveria.net → 200 OK, SPA geladen
- /health → Healthy
- /dashboard, /login → SPA-Routing korrekt
- /api/v1/operations/snapshot → 401 Unauthorized (Auth-Schutz aktiv)
- Let's Encrypt TLS-Zertifikat aktiv
- Traefik-Proxy → `nexus-web-1:80` ueber externes `proxy`-Netzwerk

## Incident-Hinweis (2026-06-14)

- Verifizierter Ausfallpfad: `api` konnte wegen DB-Passwort-Mismatch nicht healthy werden; dadurch blieb `web` per `depends_on: service_healthy` im Status `Created`.
- Nach einem isolierten API-Fix startet `web` nicht automatisch nach. Sicherer Minimalpfad:
  1. `docker compose ps`
  2. `curl http://127.0.0.1:18880/health`
  3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
  4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
- Der CD-Pfad verifiziert deshalb nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.

## Offene Arbeit

- [!] Gitea-Deploy-Trigger reparieren: `POST /actions/workflows/deploy.yaml/dispatches` liefert aktuell `HTTP 500`; fuer Commit `2d21885` war deshalb kein erfolgreicher normaler Deploy-Run belegbar
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
- [ ] Docker-Logs und Container-Health-Monitoring einrichten
- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren
- [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern

### Deploy-Trigger-Actor (2026-06-14)

- Deploy-Trigger werden durch DevOps (nicht Iris) ausgelöst
- Git-Remote origin verwendet DevOps-Token → Gitea zeigt devops als Actor
- Workflow-Dispatch API-Calls mit DevOps-Token authentifizieren
