Files
ChatApp/docs/migration-netralax-de.md
byGalax 5bc30c950c feat(infra): migrate self-hosted backend to netralax.de
Move Supabase + LiveKit from the netralax.cloud VPS to a new netralax.de server. Adds the migration runbook (docs/), one-time move scripts (scripts/migrate/), and prod Caddy/LiveKit config templates (infra/). Repoints the desktop publish/changelog URLs and prod ops config to .de. JWT_SECRET + VAPID copied identically so already-installed clients keep working; the new server also serves the legacy .cloud hostnames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:39:04 +02:00

733 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Migrations-Runbook: Self-Hosted Backend von `*.netralax.cloud` auf `*.netralax.de` (neuer VPS)
> **Zweck:** Vollständiger Umzug des selbstgehosteten Chat-Backends (Supabase + LiveKit/coturn + Update-Host) vom ALTEN VPS (`46.225.156.249`, `*.netralax.cloud`) auf einen FRISCHEN, leeren NEUEN VPS, der danach `*.netralax.de` UND während der Übergangsphase weiterhin `*.netralax.cloud` ausliefert.
>
> **Lesbar als:** Copy-paste-Runbook. Überschriften und Erklärungen sind deutsch; alle Befehle, Pfade, Variablennamen und Konfig-Snippets bleiben wörtlich/literal.
---
## ⚠️ Zwei nicht verhandelbare Kontinuitäts-Garantien (vor allem anderen lesen)
Die bereits installierten Desktop- (Vite/electron) und Mobile- (Expo) Clients tragen die ALTEN Hostnamen **und** den anon-JWT **fest im Bundle einkompiliert** (`SUPABASE_URL`, `LIVEKIT_URL`, `SUPABASE_ANON_KEY`, `VITE_VAPID_PUBLIC_KEY`, Update-Host). Daraus folgen zwei Garantien, deren Verletzung **alle bestehenden Installationen sofort und lautlos zerstört**:
1. **`JWT_SECRET` (und damit `ANON_KEY`, `SERVICE_ROLE_KEY`) MÜSSEN byte-für-byte vom ALTEN Server übernommen werden.** Der anon-JWT in den Bundles ist mit dem alten `JWT_SECRET` signiert. Ein anderes Secret → Kong/PostgREST/GoTrue verwerfen **jedes** Token → **alle** Sessions fallen aus, niemand kann sich mehr anmelden. Es gibt keine Fehlermeldung, die das offensichtlich macht.
2. **Das VAPID-Schlüsselpaar (`VAPID_PUBLIC_KEY` + `VAPID_PRIVATE_KEY`) MUSS identisch übernommen werden.** Bestehende Web-Push-Subscriptions sind an den öffentlichen VAPID-Key gebunden. Ändert er sich, brechen **alle** vorhandenen Push-Abos Benachrichtigungen verstummen lautlos.
Zusätzlich: Der NEUE Caddy **muss die Legacy-Vhosts `*.netralax.cloud` mitbedienen** und die `.cloud`-DNS-A-Records müssen auf die NEUE IP zeigen, sonst sterben alte Clients in dem Moment, in dem der alte VPS abgeschaltet wird.
---
## 0. Voraussetzungen & Übersicht
### 0.1 Architektur (unverändert auf beiden Servern)
| Komponente | Verzeichnis | Intern | Öffentlich (neu) | Öffentlich (Legacy, weiter bedient) |
|---|---|---|---|---|
| Supabase (Postgres 17, GoTrue, PostgREST, Realtime, Storage, Kong, edge-runtime, Mailpit) | `/opt/supabase` | Kong `127.0.0.1:8000` | `supabase.netralax.de` | `supabase.netralax.cloud` |
| LiveKit SFU (Signaling-WS) | `/opt/livekit` | `127.0.0.1:7880` | `livekit.netralax.de` | `livekit.netralax.cloud` |
| coturn (TURN/TURNS) | `/opt/livekit` | `:3478`, `:5349` (TLS) | `turn.netralax.de:5349` | `turn.netralax.cloud:5349` |
| Update-Host (electron-updater) | `/var/www/updates/windows` | `file_server` | `update.netralax.de` | `update.netralax.cloud` |
TLS-Terminierung für Supabase/LiveKit/Update via **Caddy** (automatisches Let's Encrypt). **TURNS auf `5349` läuft NICHT über Caddy** und braucht ein eigenes Zertifikat auf der Platte.
### 0.2 Was du brauchst
- SSH-Zugang: User `prox` auf dem **alten** (.cloud) VPS, User `debian` auf dem **neuen** (.de) VPS. Der neue VPS ist leer.
- Die NEUE öffentliche IP des `.de`-VPS: **`141.95.34.204`** (bereits in `scripts/migrate/config.sh``NEW_HOST` und `scripts/prod/config.sh``PROD_SERVER` eingetragen). Login-User: `debian`.
- Lese-Zugriff auf die ALTE `/opt/supabase/.env` (enthält alle zu kopierenden Secrets).
- DNS-Verwaltung für `netralax.de` **und** `netralax.cloud`.
- Entwickler-Laptop mit Bash (Linux/macOS/WSL), `ssh`, `rsync`, `openssl`.
- Ein Wartungsfenster (Schreibstopp auf der App), siehe Abschnitt 5.
### 0.3 Reihenfolge der Arbeit (Überblick)
```
1. DNS vorbereiten (niedrige TTL setzen, noch NICHT umbiegen)
2. Neuen VPS bootstrappen → scripts/migrate/01-bootstrap-new-server.sh
3. Secrets 1:1 in /opt/supabase/.env übernehmen (JWT_SECRET/VAPID identisch!)
4. Stacks LEER hochfahren (init der Rollen)
5. Wartungsfenster: DB + Storage migrieren → scripts/migrate/02-migrate-data.sh
6. LiveKit/coturn Prod-Config + Firewall + TURNS-Zertifikat
7. Caddy mit BEIDEN Domain-Sätzen (.de + .cloud)
8. Edge-Functions + deren Secrets deployen
9. Update-Host migrieren + Dual-Publish (.de UND .cloud)
10. Cutover: DNS scharf schalten (alle .de + Repoint aller .cloud)
11. Smoke-Tests (inkl. ALTER .cloud-Client)
12. Repo-Edits + neues Desktop-/Mobile-Release ausliefern
13. Rollback-Plan (bereithalten)
14. Aufräumen / .cloud später abschalten
```
### 0.4 Konventionen der Migrate-Skripte (Interface-Contract)
Alle `scripts/migrate/*`-Skripte sourcen `scripts/migrate/config.sh`. Dieses kennt **beide** Hosts und ist bewusst **unabhängig** von `scripts/prod/config.sh` (das bereits auf den End-Zustand `.de` zeigt). `config.sh` exportiert:
```bash
OLD_HOST="46.225.156.249"
OLD_USER="prox"
NEW_HOST="141.95.34.204"
NEW_USER="debian" # neuer .de-Server: User debian (alt: prox)
SUPABASE_DIR="/opt/supabase"
LIVEKIT_DIR="/opt/livekit"
OLD_SSH="${OLD_USER}@${OLD_HOST}"
NEW_SSH="${NEW_USER}@${NEW_HOST}"
SSH_OPTS="-o StrictHostKeyChecking=accept-new"
```
…und die Helfer `old_remote()` / `new_remote()`, die per `ssh ${SSH_OPTS}` zum jeweiligen Host verbinden.
---
## 1. DNS-Plan
> **Wichtig:** In diesem Schritt wird DNS **noch nicht** umgebogen (außer der TTL-Absenkung). Das eigentliche Scharfschalten passiert erst im **Cutover (Abschnitt 10)**, wenn der neue VPS vollständig steht und getestet ist.
### 1.1 Jetzt (Vorbereitung): TTL absenken
Setze auf **allen** unten genannten A-Records die TTL auf **300 Sekunden (5 min)**, mindestens 2448 h vor dem geplanten Cutover. So wird die spätere Umstellung schnell wirksam.
### 1.2 Beim Cutover (Abschnitt 10): A-Records auf `141.95.34.204`
**Neue `.de`-Records (anlegen):**
| Record | Typ | Ziel |
|---|---|---|
| `supabase.netralax.de` | A | `141.95.34.204` |
| `livekit.netralax.de` | A | `141.95.34.204` |
| `turn.netralax.de` | A | `141.95.34.204` |
| `update.netralax.de` | A | `141.95.34.204` |
**Legacy `.cloud`-Records (REPOINT von alter IP `46.225.156.249` auf neue IP):**
| Record | Typ | Neues Ziel |
|---|---|---|
| `supabase.netralax.cloud` | A | `141.95.34.204` |
| `livekit.netralax.cloud` | A | `141.95.34.204` |
| `turn.netralax.cloud` | A | `141.95.34.204` |
| `update.netralax.cloud` | A | `141.95.34.204` |
> **⚠️ Den Repoint der `.cloud`-Records NICHT vergessen.** Alle bereits installierten Clients sprechen `*.netralax.cloud` an. Bleiben diese Records auf der alten IP, brechen sämtliche Installationen, sobald der alte VPS abgeschaltet wird. Der neue Caddy bedient die `.cloud`-Vhosts mit (Abschnitt 7), und Let's Encrypt stellt für `.cloud` erst dann gültige Zertifikate aus, **wenn** die `.cloud`-A-Records auf die neue IP zeigen.
### 1.3 Verifikation nach dem Cutover
```bash
for h in supabase livekit turn update; do
echo "== $h.netralax.de =="; dig +short $h.netralax.de
echo "== $h.netralax.cloud =="; dig +short $h.netralax.cloud
done
```
Alle acht müssen `141.95.34.204` zurückgeben.
---
## 2. Neuen Server bootstrappen
Das Skript **`scripts/migrate/01-bootstrap-new-server.sh`** wird **auf den neuen VPS kopiert und dort als root** ausgeführt. Es ist idempotent, erfindet **keine** Secrets und gibt am Ende klare NEXT-STEP-Hinweise.
### 2.1 Skript übertragen und ausführen
```bash
# Vom Laptop aus:
scp -o StrictHostKeyChecking=accept-new \
scripts/migrate/01-bootstrap-new-server.sh \
debian@141.95.34.204:/tmp/
ssh -o StrictHostKeyChecking=accept-new debian@141.95.34.204 \
'sudo bash /tmp/01-bootstrap-new-server.sh'
```
### 2.2 Was das Bootstrap-Skript tut
- Installiert **Docker Engine + compose-plugin**.
- Installiert + aktiviert **ufw** und öffnet die Ports (siehe Abschnitt 6 für die vollständige Liste): `22/tcp`, `80/tcp`, `443/tcp`, `7880/tcp`, `7881/tcp`, `50000:50100/udp`, `3478/tcp`, `3478/udp`, `5349/tcp`, `50200:50300/udp`.
- Klont `https://github.com/supabase/supabase` und kopiert `supabase/docker/` nach **`/opt/supabase`** (inkl. `docker-compose.yml`, `volumes/`, `.env.example`). Hinweis: Wir vendoren die Supabase-Compose-Datei **nicht** im Repo sie wird beim Bootstrap frisch geklont.
- Legt **`/opt/livekit`** an und schreibt Platzhalter `docker-compose.yml` + `livekit.yaml` + `coturn.conf`.
- Installiert **Caddy** und legt eine Platzhalter-`/etc/caddy/Caddyfile` an.
- Legt **`/var/www/updates/windows`** an (Artefakt-Verzeichnis; Caddy-Docroot ist das Eltern-Verzeichnis `/var/www/updates`, siehe §7).
- Setzt in `/opt/supabase/.env` die sicherheitskritischen Secrets (`JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `POSTGRES_PASSWORD`, …) auf den Sentinel `__COPY_FROM_OLD_SERVER__`, damit ein vergessener Wert **laut scheitert** statt still die öffentlich bekannten Upstream-Defaults zu benutzen.
- Druckt am Ende die NEXT-STEPS: `/opt/supabase/.env` befüllen (Abschnitt 3), Prod-Compose + `livekit.yaml` + `coturn.conf` einsetzen (Abschnitt 6), `Caddyfile` einsetzen (Abschnitt 7).
> **Das Bootstrap-Skript erfindet KEINE Secrets.** Die sicherheitskritischen Keys stehen danach auf dem Sentinel `__COPY_FROM_OLD_SERVER__` (fail-loud); Custom-Secrets wie `VAPID_*` / `PUSH_FANOUT_SHARED_SECRET` sind im Upstream-`.env` gar nicht vorhanden und müssen ergänzt werden. Alle echten Werte kommen in Abschnitt 3 vom alten Server.
---
## 3. Secrets 1:1 übernehmen
Alle Server-Secrets leben auf dem Server in **`/opt/supabase/.env`**. Hole zuerst die ALTE Datei:
```bash
# ALTE .env lokal sichern (nur lesend, nichts ändern):
ssh -o StrictHostKeyChecking=accept-new prox@46.225.156.249 \
'cat /opt/supabase/.env' > old.env.backup
chmod 600 old.env.backup
```
### 3.1 Entscheidungstabelle: identisch kopieren vs. auf neuen Host umstellen
**Spalte „Aktion": `IDENTISCH` = byte-für-byte aus `old.env.backup` übernehmen; `NEU` = auf den neuen Host/Wert setzen.**
| Variable | Aktion | Woher / Neuer Wert | Begründung |
|---|---|---|---|
| `POSTGRES_PASSWORD` | **IDENTISCH** | old.env | Dump trägt Rollen-Passwort-Hashes; muss vor Restore passen, sonst können interne Dienste sich nicht an Postgres anmelden. |
| `JWT_SECRET` | **🔴 IDENTISCH** | old.env | **Signiert die eingebackenen anon/service-role-JWTs. Abweichung = alle Sessions tot.** |
| `ANON_KEY` | **🔴 IDENTISCH** | old.env | Eingebackener anon-JWT der Clients. |
| `SERVICE_ROLE_KEY` | **IDENTISCH** | old.env | service-role-JWT für Edge-Functions/Admin-Skripte; muss zu `JWT_SECRET` passen. |
| `SECRET_KEY_BASE` | **IDENTISCH** | old.env | Realtime (Phoenix) + Vault: signiert Channel-Tokens/Cookies. |
| `VAULT_ENC_KEY` | **IDENTISCH** | old.env | Entschlüsselt vault/pgsodium-verschlüsselte Zeilen aus dem Dump. |
| `PG_META_CRYPTO_KEY` | **IDENTISCH** | old.env | postgres-meta-Crypto-Key; stabil halten. |
| `SMTP_HOST` | **IDENTISCH** | old.env | Magic-Link-Mailversand erhalten (externes Relay / Mailpit). |
| `SMTP_PORT` | **IDENTISCH** | old.env | s.o. |
| `SMTP_USER` | **IDENTISCH** | old.env | s.o. |
| `SMTP_PASS` | **IDENTISCH** | old.env | s.o. |
| `SMTP_ADMIN_EMAIL` | **IDENTISCH** | old.env | Absender/SPF-Konsistenz. |
| `SMTP_SENDER_NAME` | **IDENTISCH** | old.env | Anzeigename konsistent. |
| `FUNCTIONS_VERIFY_JWT` | **IDENTISCH** | old.env (`false`) | `notify-push` nutzt Shared-Secret-Header statt User-JWT; bleibt `false`. |
| `LIVEKIT_API_KEY` | **IDENTISCH** | old.env | Muss = `keys:`-Block in `livekit.prod.yaml`, sonst SFU-Reject (403). |
| `LIVEKIT_API_SECRET` | **IDENTISCH** | old.env | s.o. |
| `VAPID_PUBLIC_KEY` | **🔴 IDENTISCH** | old.env | **Bindet bestehende Push-Abos. Abweichung = alle Push-Subscriptions tot.** |
| `VAPID_PRIVATE_KEY` | **🔴 IDENTISCH** | old.env | Muss mit unverändertem Public-Key paaren. |
| `VAPID_SUBJECT` | **IDENTISCH** | old.env | Konsistenz (mailto/URL). |
| `PUSH_FANOUT_SHARED_SECRET` | **IDENTISCH** | old.env | `x-shared-secret`-Header zwischen DB-Trigger und `notify-push`. |
| `SUPABASE_SERVICE_ROLE_KEY` | **IDENTISCH** | = `SERVICE_ROLE_KEY` | Edge-Function-Alias. |
| `SUPABASE_ANON_KEY` | **IDENTISCH** | = `ANON_KEY` | Edge-Function-Alias (mint-livekit-token RLS-Client). |
| `SITE_URL` | **NEU** | `https://supabase.netralax.de` | GoTrue-Basis-URL für Magic-Link-Redirects. |
| `API_EXTERNAL_URL` | **NEU** | `https://supabase.netralax.de` | Öffentliche Kong-URL, die GoTrue/Studio bewerben. |
| `SUPABASE_PUBLIC_URL` | **NEU** | `https://supabase.netralax.de` | Studio/Kong-Asset-/Link-Generierung. |
| `ADDITIONAL_REDIRECT_URLS` | **NEU** (Superset) | siehe 3.2 | GoTrue-Redirect-Allow-List inkl. Deep-Link-Schemata. |
| `SUPABASE_URL` (Edge-Function) | **NEU** | `https://supabase.netralax.de` (oder internes Kong) | Funktionen müssen es nur erreichen. |
| `LIVEKIT_URL` (Edge-Function) | **NEU** | `wss://livekit.netralax.de` | wss-URL für neue Builds; alte Clients nutzen `.cloud` (vom neuen Caddy mitbedient). |
| `DASHBOARD_USERNAME` | **NEU** | frei wählbar | Studio-Basic-Auth; nicht client-kritisch. |
| `DASHBOARD_PASSWORD` | **NEU** | starkes neues Passwort | s.o. |
| `POSTGRES_HOST` | Default | `db` | nicht host-spezifisch. |
| `POSTGRES_DB` | Default | `postgres` | s.o. |
| `POSTGRES_PORT` | Default | `5432` (nur an localhost gebunden) | s.o. |
| `KONG_HTTP_PORT` | Default | `8000` | muss zum Caddyfile passen. |
| `KONG_HTTPS_PORT` | Default | `8443` (ungenutzt) | Caddy terminiert TLS. |
### 3.2 `ADDITIONAL_REDIRECT_URLS` (exakt, ohne Leerzeichen)
```
ADDITIONAL_REDIRECT_URLS=chatapp://auth/callback,netralax://auth/callback,https://supabase.netralax.de,https://supabase.netralax.cloud
```
> **⚠️ GoTrue lehnt jeden Magic-Link-Redirect ab, der nicht exakt auf der Allow-List steht.** Beide Deep-Link-Schemata (`chatapp://auth/callback` **und** `netralax://auth/callback`) müssen drin sein, sonst scheitert der Native-App-Login.
### 3.3 Werte übertragen
Bearbeite `/opt/supabase/.env` auf dem neuen Server und setze die `IDENTISCH`-Werte aus `old.env.backup`, die `NEU`-Werte aus der Tabelle:
```bash
ssh debian@141.95.34.204 'sudo nano /opt/supabase/.env'
```
> **Reihenfolge-Falle:** `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`, `POSTGRES_PASSWORD` und das VAPID-Paar müssen in der `.env` stehen, **bevor** in Abschnitt 4 der Stack hochfährt und **bevor** in Abschnitt 5 der Restore läuft. Setze sie jetzt vollständig.
### 3.4 Verifikation (Hashes vergleichen, nicht Klartext loggen)
```bash
# Stelle sicher, dass die kritischen Secrets identisch sind:
for v in JWT_SECRET ANON_KEY SERVICE_ROLE_KEY POSTGRES_PASSWORD VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY; do
old=$(ssh prox@46.225.156.249 "grep -E \"^${v}=\" /opt/supabase/.env | cut -d= -f2-" | sha256sum)
new=$(ssh debian@141.95.34.204 "grep -E \"^${v}=\" /opt/supabase/.env | cut -d= -f2-" | sha256sum)
[ "$old" = "$new" ] && echo "OK $v" || echo "DIFF $v <-- FIX BEFORE RESTORE"
done
```
Jede Zeile muss `OK` sein.
---
## 4. Stacks leer hochfahren
Bevor Daten restauriert werden, muss der frische Supabase-Stack **einmal** hochfahren, damit die Init-Skripte die Rollen anlegen (`supabase_admin`, `authenticator`, `anon`, `authenticated`, `service_role`, `supabase_auth_admin`, `supabase_storage_admin`, …), Extensions und Grants. Voraussetzung: `POSTGRES_PASSWORD` und `JWT_SECRET` sind bereits identisch gesetzt (Abschnitt 3).
```bash
# DB-Container zuerst hochfahren (legt Rollen + Extensions an):
ssh debian@141.95.34.204 \
'cd /opt/supabase && docker compose up -d db && sleep 20'
# Health-Check:
ssh debian@141.95.34.204 \
'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres'
```
> Den **vollständigen** Stack (`docker compose up -d`) fahren wir erst **nach** dem Daten-Restore hoch (Abschnitt 5, Schritt 3), damit alle Dienste gegen die wiederbefüllte DB neu verbinden.
---
## 5. Datenmigration: DB + Storage
Genutzt wird **`scripts/migrate/02-migrate-data.sh`** (läuft vom Laptop, sourct `config.sh`, `set -euo pipefail`, jeder destruktive Schritt ist abgesichert).
### 5.1 🔴 Wartungsfenster: Schreibstopp ZUERST
> **Friere Schreibvorgänge ein, bevor du dumpst und bevor du Storage rsyncst.** Sonst werden DB-Zeilen und Storage-Volume inkonsistent (Objekte auf der Platte ohne Metadaten-Zeile oder umgekehrt). Setze die App in Wartungsmodus / stoppe neue Uploads/Nachrichten auf dem ALTEN System.
Pre-Flight (beide Stacks gesund):
```bash
ssh prox@46.225.156.249 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres'
ssh debian@141.95.34.204 'cd /opt/supabase && docker compose exec -T db pg_isready -U postgres'
```
### 5.2 Postgres (Major-Version 17) `pg_dumpall`, gestreamt ALT → NEU
Faithful Full-Cluster-Dump (Rollen **inkl. Passwort-Hashes** + alle DBs + auth/storage/realtime/public-Schemata), direkt vom alten in den neuen Container gestreamt:
```bash
old_remote 'cd /opt/supabase && docker compose exec -T db pg_dumpall -U postgres --clean --if-exists' \
| new_remote 'cd /opt/supabase && docker compose exec -T db psql -U postgres -d postgres -v ON_ERROR_STOP=0'
```
Wichtige Hinweise zu diesem Befehl:
- **`pg_dumpall` (nicht `pg_dump`)** ist nötig, weil es die ROLLEN-Definitionen samt Passwort-Hashes (md5/scram) mitnimmt. Da `POSTGRES_PASSWORD` auf beiden Hosts identisch ist, passen die restaurierten Rollen-Passwörter zu dem, was die Dienste benutzen.
- **`--clean --if-exists`** macht den Dump gegen den bereits initialisierten Cluster wiederholbar (droppt/erzeugt Objekte neu).
- **`ON_ERROR_STOP=0` (nicht `=1`):** `pg_dumpall` versucht, bereits existierende Rollen wie `supabase_admin`/`postgres` per `CREATE ROLE` anzulegen → harmlose „already exists"-Fehler. Mit `ON_ERROR_STOP=1` würde der erste davon einen guten Restore abbrechen. `=0` schluckt aber **auch echte Fehler** (FK/Constraint/Ownership) und hinterlässt eine teil-restaurierte DB, die „erfolgreich" aussieht. **Deshalb scannt `02-migrate-data.sh` den Restore automatisch:** es teet die Ausgabe in ein Log, grept nach `ERROR/FATAL/PANIC` abzüglich der harmlosen Muster und **bricht VOR dem Storage-rsync ab**, falls echte Fehler übrig bleiben (bewusster Override: `FORCE_RESTORE_OK=1`).
- Erfasst in einem Rutsch **alle** Schemata: `auth` (User/Identities/Sessions), `storage` (Buckets + Objekt-Metadaten), `realtime` (Tenants/Subscriptions), `public` (App-Tabellen), ggf. `_realtime`/`_analytics`.
> **🔴 Migrationen NICHT erneut anwenden.** Alle Migrationen stecken bereits im Dump. **`scripts/prod/push-migrations.sh` nach dem Restore NICHT ausführen** das riskiert Drift/Duplicate-Object-Fehler.
**Alternative (nur falls Cluster-Level scheitert):** Single-DB `pg_dump -Fc` + `pg_restore --clean --if-exists --no-owner`, plus separat `pg_dumpall --roles-only`. Der `pg_dumpall`-Pfad oben ist für self-hosted→self-hosted vorzuziehen.
### 5.3 Vollständigen Stack neu hochfahren
```bash
new_remote 'cd /opt/supabase && docker compose down && docker compose up -d'
```
### 5.4 Storage-Objekte `rsync` (ALT → NEU)
Die Objekt-Bytes liegen unter `/opt/supabase/volumes/storage` (Bind-Mount → Container `/var/lib/storage`); die Metadaten-Zeilen kamen bereits mit dem Dump. **Schreibstopp muss noch aktiv sein.** Trailing-Slashes beachten:
```bash
# Direkt ALT -> NEU (Daten fließen Server-zu-Server, wenn alt den neuen erreicht):
old_remote "sudo rsync -aHAX --numeric-ids --delete \
-e 'ssh -o StrictHostKeyChecking=accept-new' \
/opt/supabase/volumes/storage/ ${NEW_USER}@${NEW_HOST}:/opt/supabase/volumes/storage/"
```
Falls die Server sich gegenseitig **nicht** per SSH erreichen, zwei-stufig über den Laptop:
```bash
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" ${OLD_USER}@${OLD_HOST}:/opt/supabase/volumes/storage/ ./_storage_stage/
rsync -aHAX --numeric-ids --delete -e "ssh ${SSH_OPTS}" ./_storage_stage/ ${NEW_USER}@${NEW_HOST}:/opt/supabase/volumes/storage/
```
- `-aHAX` erhält Hardlinks/ACLs/xattrs; `--delete` macht das Ziel zum exakten Spiegel (**nur sicher bei eingefrorenen Schreibvorgängen**).
Danach Storage-Service neu starten, damit die UID-/Ownership-Erwartung passt:
```bash
new_remote 'cd /opt/supabase && docker compose restart storage imgproxy'
```
### 5.5 Daten-Verifikation
```bash
# Tabellen-/User-Counts vergleichen (Beispiel):
new_remote 'cd /opt/supabase && docker compose exec -T db psql -U postgres -d postgres \
-c "select count(*) as users from auth.users;" \
-c "select count(*) as objects from storage.objects;"'
```
`02-migrate-data.sh` macht zusätzlich eine **Zeilen-Paritätsprüfung OLD vs NEU** über tragende Tabellen (`auth.users`, `auth.identities`, `public.profiles`, `public.messages`, `public.conversation_members`, `storage.objects`) und meldet jede Abweichung — eine reine User-/Objekt-Zählung würde Teilverluste in `messages`/`members` übersehen. Ein bekanntes Objekt sollte zudem über das neue Gateway ladbar sein (Test nach Caddy-Setup, Abschnitt 11).
> **Cold-Volume-Copy-Alternative:** Nur falls Image-Tags byte-identisch sind, kann man statt Logical-Dump **beide** DBs stoppen und `volumes/db/data` (PGDATA) **plus** das `db-config`-Named-Volume (enthält den pgsodium-Key) rsyncen. Nur mit gestoppten DBs und identischen Postgres-Image-Tags; ansonsten den Logical-Dump oben bevorzugen.
---
## 6. LiveKit/coturn Prod-Config + Firewall-Ports + TURNS-Zertifikat
> **Die Prod-Config unterscheidet sich von der Dev-`infra/livekit/livekit.yaml` im Repo.** Prod setzt `rtc.use_external_ip: true` und enthält **KEIN** `node_ip: 127.0.0.1` (das ist Dev-only).
> **🟢 Sicherster Weg — die ALTE, funktionierende Config übernehmen.** Die `.example`-Templates sind eine Referenz; produktiv erprobt ist aber die Config, die auf dem alten Server **bereits läuft**. Hol dir die echten Dateien vom alten VPS und ändere nur das Nötigste — so bleibt insbesondere erhalten, **wie** den Clients die TURN-Server/ICE-Credentials angekündigt werden (das macht der alte `livekit.yaml`-`turn:`/`rtc:`-Block bzw. die coturn-`user=`-Zeile; `mint-livekit-token` liefert nur LiveKit-URL+Token, nicht die TURN-Creds):
> ```bash
> # vom Laptop:
> scp prox@46.225.156.249:/opt/livekit/livekit.yaml ./_livekit_old.yaml
> scp prox@46.225.156.249:/opt/livekit/coturn.conf ./_coturn_old.conf
> # dann NUR anpassen: external-ip (neue IP), cert/pkey-Pfade (turn.netralax.de),
> # und — falls vorhanden — eine externe IP/Domain im livekit.yaml turn-Block.
> # Danach als /opt/livekit/{livekit.yaml,coturn.conf} auf den neuen Server.
> ```
> Wenn die alten Dateien nicht greifbar sind, nutze die Templates unten und stelle sicher, dass die coturn-`user=`-Credentials zu dem passen, was deine Clients heute für TURN verwenden.
### 6.1 Prod-Compose + `livekit.yaml` + `coturn.conf` einsetzen
Auf dem alten Server lief LiveKit/coturn über ein Compose in `/opt/livekit`. Das Repo liefert dafür **`infra/livekit/docker-compose.prod.yml.example`** (die Dev-`infra/livekit/docker-compose.yml` ist **nicht** prod-tauglich: coturn läuft dort mit `--no-tls`, ohne `5349`, ohne Zertifikat). Drei Dateien auf den Server kopieren — die **on-server-Namen** sind bewusst `livekit.yaml` / `coturn.conf` (genau die, die auch `scripts/prod/rotate-livekit-keys.sh` editiert):
| Repo-Template | → on-server |
|---|---|
| `infra/livekit/docker-compose.prod.yml.example` | `/opt/livekit/docker-compose.yml` |
| `infra/livekit/livekit.prod.yaml.example` | `/opt/livekit/livekit.yaml` |
| `infra/livekit/coturn.prod.conf.example` | `/opt/livekit/coturn.conf` |
`keys:`-Block in **`/opt/livekit/livekit.yaml`** mit den Werten aus Abschnitt 3 (`LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET`) füllen:
```yaml
port: 7880
log_level: info
rtc:
tcp_port: 7881
port_range_start: 50000
port_range_end: 50100
use_external_ip: true
# KEIN node_ip: 127.0.0.1 — das ist dev-only und würde alle Remote-Clients
# ihre Medien an den eigenen Loopback schicken lassen (Call ohne Audio/Video).
keys:
__LIVEKIT_API_KEY__: __LIVEKIT_API_SECRET__
turn:
enabled: false # coturn läuft separat
```
> **🔴 `node_ip: 127.0.0.1` aus der Dev-Config NICHT übernehmen.** Sonst verbinden Calls zwar, haben aber **keinen Ton und kein Bild**, weil jeder Remote-Client Medien an seinen eigenen Loopback sendet.
>
> **🔴 `LIVEKIT_API_KEY`/`SECRET` im `keys:`-Block MÜSSEN exakt den Edge-Function-Werten in `/opt/supabase/.env` entsprechen.** Sonst signiert `mint-livekit-token` Tokens, die der SFU mit 403 ablehnt.
### 6.2 coturn Prod-Config einsetzen
Template: **`infra/livekit/coturn.prod.conf.example`** → **`/opt/livekit/coturn.conf`**. Die Zertifikatspfade zeigen auf `/etc/letsencrypt/...` — genau das Verzeichnis, das das Prod-Compose read-only in den coturn-Container einhängt:
```conf
realm=netralax.de
listening-port=3478
tls-listening-port=5349
external-ip=141.95.34.204
min-port=50200
max-port=50300
cert=/etc/letsencrypt/live/turn.netralax.de/fullchain.pem
pkey=/etc/letsencrypt/live/turn.netralax.de/privkey.pem
lt-cred-mech
user=__TURN_USER__:__TURN_PASSWORD__
fingerprint
no-multicast-peers
```
### 6.3 TURNS-Zertifikat für `turn.netralax.de` (NICHT über Caddy)
> **TURNS auf `5349` geht NICHT durch Caddy** coturn braucht ein eigenes TLS-Cert+Key auf der Platte (`cert`/`pkey`-Pfade oben). Ein reines Caddy-Cert deckt das nicht ab.
Zwei Wege, das Zertifikat bereitzustellen:
**A) certbot standalone (empfohlen, einfachster Pfad).** Schreibt direkt nach `/etc/letsencrypt/live/turn.netralax.de/` — also genau die Pfade, die `coturn.conf` referenziert und die das Prod-Compose in den Container einhängt. Kein Kopieren nötig:
```bash
# Port 80 muss kurz frei sein (Caddy ggf. stoppen oder DNS-01 nutzen):
sudo certbot certonly --standalone -d turn.netralax.de
# Renewal-Hook, damit coturn das erneuerte Cert lädt:
sudo certbot renew --deploy-hook 'docker compose -f /opt/livekit/docker-compose.yml restart turn'
```
**B) Caddy-Cert wiederverwenden.** Caddy hat ohnehin ein gültiges Cert für `turn.netralax.de`, sobald der DNS-Record steht und der Host in der Caddy-Config ist. PEM/Key aus Caddys Storage (`/var/lib/caddy/.local/share/caddy/certificates/...`) an die `/etc/letsencrypt/live/turn.netralax.de/`-Pfade symlinken/kopieren und coturn nach Renewals neu starten. Umständlicher als (A) — nur, wenn certbot nicht in Frage kommt.
> coturn liest das Cert **beim Start**; nach jeder Erneuerung den `turn`-Container neu starten (Hook oben). Das `external-ip` muss die **neue** öffentliche IP sein.
### 6.4 Firewall-Ports (ufw) ALLE öffnen, sonst kein A/V
> Diese Ports **umgehen Caddy** und müssen direkt in ufw offen sein. Fehlt einer, haben Calls **keinen Ton/kein Bild**.
```bash
ssh debian@141.95.34.204 'sudo bash -s' <<'EOF'
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 7880/tcp # LiveKit Signaling (hinter Caddy)
ufw allow 7881/tcp # RTC TCP-Fallback
ufw allow 50000:50100/udp # RTC Media
ufw allow 3478/udp # coturn STUN/TURN
ufw allow 3478/tcp # coturn STUN/TURN
ufw allow 5349/tcp # coturn TURNS (TLS)
ufw allow 50200:50300/udp # coturn TURN-Relay
ufw --force enable
ufw status verbose
EOF
```
> **Postgres NICHT öffentlich öffnen.** `5432` bleibt nur an `localhost` gebunden (wie auf dem alten Server). Für Remote-`psql` das bestehende Tunnel-Muster nutzen: `./scripts/prod/tunnel-db.sh` (SSH-Tunnel `localhost:5433 → server:5432`).
### 6.5 LiveKit-Stack starten
Voraussetzung: `/opt/livekit/docker-compose.yml` ist das **Prod**-Compose aus §6.1 (host-networking, mountet `livekit.yaml` + `coturn.conf` + `/etc/letsencrypt`), nicht das Dev-Compose.
```bash
ssh debian@141.95.34.204 'cd /opt/livekit && docker compose up -d && docker compose ps'
# coturn lauscht jetzt auf 5349/TLS? prüfen:
ssh debian@141.95.34.204 'ss -tlnp | grep -E "5349|3478" ; docker compose -f /opt/livekit/docker-compose.yml logs turn --tail=20'
```
---
## 7. Caddy mit BEIDEN Domain-Sätzen (.de + .cloud Legacy)
Template: **`infra/caddy/Caddyfile`** → auf dem Server `/etc/caddy/Caddyfile`. Caddy terminiert TLS (automatisches Let's Encrypt) und reverse-proxyt Klartext-HTTP an die lokalen Backends. **Pro Vhost genau EIN `reverse_proxy`** Kong multiplext bereits alle Supabase-Routen; keine Pfad-Splits in Caddy.
```caddyfile
# Caddyfile — Dual-Domain-Übergang .cloud -> .de
#
# Während der Migration bedient dieser Caddy BEIDE Domain-Sätze aus denselben
# lokalen Backends:
# - *.netralax.de = neue, primäre Hostnamen (neue Client-Builds)
# - *.netralax.cloud = Legacy-Hostnamen, die in bereits installierten
# Desktop-/Mobile-Bundles fest einkompiliert sind.
# Die .cloud-DNS-A-Records zeigen (nach dem Cutover) auf DIESELBE neue IP, damit
# alte Installationen weiterlaufen, bis sie sich selbst auf .de aktualisieren.
# NICHT entfernen, solange noch alte Clients .cloud ansprechen (siehe Abschnitt 14).
# AKTIV ab jetzt: nur die .de-Hosts. Die .cloud-Blöcke stehen auskommentiert
# darunter und werden ERST beim Cutover (§10) aktiviert — sonst läuft Caddy ins
# Let's-Encrypt-Rate-Limit, weil .cloud-DNS noch auf den alten Server zeigt.
# --- Supabase (Kong-Gateway :8000 multiplext auth/rest/realtime/storage/functions/Studio) ---
# Realtime-WS (/realtime/v1/websocket) wird von reverse_proxy transparent upgegradet.
supabase.netralax.de {
reverse_proxy localhost:8000
}
# --- LiveKit Signaling-WS (:7880). Caddy reicht Upgrade/Connection-Header durch. ---
livekit.netralax.de {
reverse_proxy localhost:7880
}
# --- Update-Host (electron-updater: latest.yml + .exe + changelog.json) ---
# 🔴 docroot ist /var/www/updates, NICHT .../windows: release.mjs lädt nach
# /var/www/updates/windows/ hoch, Clients holen unter URL-Pfad /windows/…
# Mit root=.../windows entstünde /windows/windows/ → 404 für JEDES Update.
update.netralax.de {
root * /var/www/updates
file_server
}
# --- CUTOVER (§10): erst NACH .cloud-DNS-Repoint einkommentieren + caddy reload ---
# supabase.netralax.cloud { reverse_proxy localhost:8000 }
# livekit.netralax.cloud { reverse_proxy localhost:7880 }
# update.netralax.cloud { root * /var/www/updates
# file_server }
```
> **🔴 Pfad-Matcher, die WS-Endpunkte ausschließen, sind tabu.** Caddy v2 reicht WebSocket-Upgrades transparent durch aber nur, wenn der **ganze** Host reverse-proxyt wird (kein Sub-Path-Matching). Das gilt für Realtime (`/realtime/v1/websocket`) **und** LiveKit (`/rtc`). Es gibt kein „websocket"-Flag und es wird keins gebraucht.
Aktivieren:
```bash
ssh debian@141.95.34.204 'sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy'
```
> Let's Encrypt stellt für die `.cloud`-Namen erst gültige Zertifikate aus, **nachdem** die `.cloud`-A-Records auf die neue IP zeigen (Cutover, Abschnitt 10). Bis dahin schlägt die Cert-Ausstellung für `.cloud` fehl das ist erwartbar und löst sich mit dem DNS-Repoint.
---
## 8. Edge-Functions deployen + Secrets
Edge-Functions liegen im Repo unter `supabase/functions/`: **`mint-livekit-token`**, **`notify-push`**, **`og-preview`**. Deploy via bestehendem Skript (kopiert `supabase/functions/<name>/` nach `/opt/supabase/volumes/functions/<name>/` und startet `functions`-Container neu).
> **Achtung Host-Pinning des Deploy-Skripts:** `scripts/prod/push-edge-function.sh` sourct `scripts/prod/config.sh`, das auf `PROD_SERVER="141.95.34.204"` (neuer `.de`-VPS, User `debian`) zeigt. Diese Befehle pushen also auf den NEUEN Server — erst ausführen, nachdem Bootstrap + Secrets dort stehen:
```bash
./scripts/prod/push-edge-function.sh mint-livekit-token
./scripts/prod/push-edge-function.sh notify-push
./scripts/prod/push-edge-function.sh og-preview
```
### 8.1 Erwartete Edge-Function-Secrets in `/opt/supabase/.env`
Aus dem Code verifiziert; alle in `/opt/supabase/.env` (in Abschnitt 3 bereits gesetzt):
`LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_URL`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`, `PUSH_FANOUT_SHARED_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ANON_KEY`.
Erinnerung: `FUNCTIONS_VERIFY_JWT=false` lassen (notify-push gatet über `x-shared-secret`-Header, nicht über User-JWT).
> **🔴 Custom-Secrets müssen den `functions`-Container auch erreichen.** Im **frisch geklonten** Supabase-Compose bekommt der `functions`-Service nur die env-Variablen, die in seinem `environment:`/`env_file:`-Block stehen. `LIVEKIT_API_KEY/SECRET`, `VAPID_*`, `PUSH_FANOUT_SHARED_SECRET` und `SUPABASE_ANON_KEY` sind **Custom-Variablen** und stehen dort per Default **nicht** drin. Auf dem alten Server ist das verdrahtet (es läuft ja) — auf dem neuen muss es nachgezogen werden: entweder `env_file: .env` am `functions`-Service ergänzen oder die Variablen explizit in dessen `environment:` listen. Sonst sieht `mint-livekit-token` leere Strings → `livekit-not-configured` (500) und `notify-push` lehnt mangels `SHARED_SECRET` jede Anfrage ab.
### 8.2 Verifikation
```bash
# 1) Erreichen die Secrets den Container wirklich? (vor dem Funktionstest!)
ssh debian@141.95.34.204 'cd /opt/supabase && docker compose exec -T functions \
env | grep -E "LIVEKIT_API_KEY|LIVEKIT_API_SECRET|VAPID_PUBLIC_KEY|PUSH_FANOUT_SHARED_SECRET|SUPABASE_ANON_KEY"'
# -> Es müssen NICHT-leere Werte erscheinen. Fehlt einer: env_file/environment im
# functions-Service nachziehen und 'docker compose up -d functions'.
# 2) Logs:
./scripts/prod/logs.sh # bzw. docker compose logs functions --tail=20
# 403 bei mint-livekit-token? -> LIVEKIT_API_KEY/SECRET stimmen nicht mit /opt/livekit/livekit.yaml überein.
```
---
## 9. Update-Host migrieren + Dual-Publish (.de UND .cloud)
Der Update-Host ist ein statisches Verzeichnis `/var/www/updates/windows` mit `latest.yml`, `.exe`-Installern und `changelog.json`, ausgeliefert per `file_server` (Abschnitt 7). SSH-Deploy-User: `chatapp-deploy`.
### 9.1 Bestehende Artefakte ALT → NEU spiegeln
```bash
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \
chatapp-deploy@46.225.156.249:/var/www/updates/windows/ ./_updates_stage/
rsync -aHAX --numeric-ids -e "ssh ${SSH_OPTS}" \
./_updates_stage/ chatapp-deploy@141.95.34.204:/var/www/updates/windows/
```
### 9.2 Dual-Publish-Garantie
Beide Hosts (`update.netralax.de` und ab Cutover `update.netralax.cloud`) haben im Caddyfile denselben docroot **`/var/www/updates`** (nicht `…/windows`). Die Artefakte liegen physisch in `/var/www/updates/windows/` und werden so unter dem URL-Pfad `/windows/latest.yml` usw. ausgeliefert unter **beiden** Hosts aus **einem** Verzeichnis. (Den Docroot-Fallstrick `/windows/windows/` → 404 siehe §7.)
> **🔴 Alte Clients prüfen `update.netralax.cloud`.** Liegt die Switch-over-Release nicht (auch) unter `.cloud`, können alte Installationen sich **niemals** auf `.de` aktualisieren. Der `changelog.ts` der neuen Builds zeigt zwar auf `https://update.netralax.de/windows/changelog.json`, aber die im Bundle der **alten** Clients eingebackene URL ist `.cloud` beide müssen funktionieren.
### 9.3 Deploy-Konfiguration
`.env.release` ist bereits gesetzt (`UPDATE_HOST=update.netralax.de`, `UPDATE_SSH_USER=chatapp-deploy`, `UPDATE_REMOTE_PATH=/var/www/updates/windows`). Stelle sicher, dass der Deploy-User `chatapp-deploy` auf dem neuen VPS existiert und Schreibrechte auf `/var/www/updates/windows` hat.
---
## 10. Cutover & DNS scharf schalten
> **Erst hier wird DNS umgebogen.** Voraussetzung: Abschnitte 29 abgeschlossen, neuer VPS steht, Stacks laufen, Caddy lädt (für `.de` bereits mit gültigem Cert), Storage + DB migriert, Wartungsfenster ggf. noch aktiv.
### 10.1 Reihenfolge
1. **`.de`-A-Records anlegen** (Abschnitt 1.2, neue Records) → Caddy holt sofort Let's-Encrypt-Certs für `.de`.
2. Interner Smoke-Test über `.de` (Abschnitt 11) **bevor** alte Clients umgeschwenkt werden.
3. **`.cloud`-A-Records repointen** auf `141.95.34.204` (Abschnitt 1.2, Legacy-Records) → Caddy stellt jetzt auch für `.cloud` Certs aus; alte Clients landen ab jetzt auf dem neuen VPS.
4. Propagation prüfen (Abschnitt 1.3).
5. **Wartungsmodus aufheben**, Schreibvorgänge auf dem **neuen** System freigeben.
### 10.2 Verifikation der TLS-Ausstellung
```bash
for h in supabase.netralax.de supabase.netralax.cloud livekit.netralax.de livekit.netralax.cloud update.netralax.de update.netralax.cloud; do
echo "== $h =="
echo | openssl s_client -connect "$h:443" -servername "$h" 2>/dev/null | openssl x509 -noout -subject -dates
done
```
Jeder Host muss ein gültiges, nicht abgelaufenes Cert liefern.
---
## 11. Smoke-Test-Checkliste
Nach dem Cutover, in dieser Reihenfolge:
### 11.1 Supabase / Auth / Magic-Link
- [ ] `https://supabase.netralax.de/auth/v1/health` und `https://supabase.netralax.cloud/auth/v1/health` liefern `200`.
- [ ] **Login per Magic-Link, pro Plattform mit dem JEWEILS registrierten Schema** testen: **Desktop** über `chatapp://auth/callback`, **Mobile** über `netralax://auth/callback` (das in `apps/mobile/app.json` registrierte Schema). `ADDITIONAL_REDIRECT_URLS` enthält beide, daher akzeptiert GoTrue beides — aber das OS routet nur das tatsächlich registrierte Schema zurück in die App.
> ⚠️ Vorbestehend (nicht durch den Umzug verursacht): `apps/mobile/.env.local` setzt aktuell `EXPO_PUBLIC_AUTH_REDIRECT_URL=chatapp://auth/callback`, `app.json` registriert aber nur `netralax://`. Für funktionierende Mobile-Magic-Links sollte das App-Team den Mobile-Wert auf `netralax://auth/callback` setzen (Desktop bleibt `chatapp://`). Außerhalb des Server-Umzugs — hier nur als Flag.
- [ ] PostgREST-Zugriff mit dem **eingebackenen** anon-Key wird akzeptiert (kein 401 wegen falschem `JWT_SECRET`):
```bash
curl -s -H "apikey: <ANON_KEY>" "https://supabase.netralax.de/rest/v1/" | head
```
### 11.2 Nachricht senden / Realtime
- [ ] Zwei eingeloggte Clients: Nachricht von A erscheint bei B in Echtzeit (Realtime-WS `/realtime/v1/websocket` über Caddy).
- [ ] Storage: Upload + Re-Download eines Bildes (`/storage/v1/object/...`) funktioniert (DB-Metadaten + Volume-Bytes konsistent).
### 11.3 Voice-Call mit echtem Ton (über TURN)
- [ ] **Call zwischen zwei Geräten in unterschiedlichen Netzen** (mind. eins hinter NAT/CGNAT, das TURN erzwingt): Verbindung steht **und es ist echter Ton/Bild hörbar/sichtbar**.
- [ ] Bestätigt indirekt: `rtc.use_external_ip: true`, **kein** `node_ip: 127.0.0.1`, alle Media-Ports offen, TURNS-Cert für `turn.netralax.de` gültig.
- [ ] `mint-livekit-token` liefert ein Token, das der SFU akzeptiert (kein 403 → Keys stimmen mit `/opt/livekit/livekit.yaml` überein).
### 11.4 Web-Push
- [ ] Ein **bestehender** (vor der Migration angelegter) Push-Abonnent erhält weiterhin Benachrichtigungen → bestätigt identisches VAPID-Paar.
- [ ] Neue Subscription + Test-Push über `notify-push` (mit korrektem `x-shared-secret` / `PUSH_FANOUT_SHARED_SECRET`) kommt an.
### 11.5 Auto-Update-Check von einem ALTEN `.cloud`-Client
- [ ] `latest.yml` ist unter **beiden** Hosts mit echtem `200` abrufbar (nicht nur „erreichbar" — der Docroot-Bug aus §7 würde hier 404 liefern):
```bash
curl -sI https://update.netralax.de/windows/latest.yml | head -1 # HTTP/2 200
curl -sI https://update.netralax.cloud/windows/latest.yml | head -1 # HTTP/2 200
curl -sI https://update.netralax.cloud/windows/changelog.json | head -1
```
- [ ] Eine **bestehende, alte** Desktop-Installation (Hostnamen `.cloud` eingebacken) prüft auf Updates: electron-updater findet die Switch-over-Release, lädt sie und installiert.
- [ ] Nach dem Update zeigt der Client auf `.de` (neue Bundle-Werte) und funktioniert vollständig (Login, Nachricht, Call, Push).
> **Dieser letzte Test ist der wichtigste.** Er beweist den gesamten Übergangspfad: alter Client → `.cloud` (neue IP) → lädt Update → wird zu `.de`-Client.
---
## 12. Repo-Änderungen + neues Release bauen/ausliefern
### 12.1 Bereits gemachte Edits (verifiziert im Repo)
| Datei | Änderung | Status |
|---|---|---|
| `scripts/prod/config.sh` | `PROD_SERVER="141.95.34.204"`, `PROD_DOMAIN_SUPABASE=supabase.netralax.de`, `PROD_DOMAIN_LIVEKIT=livekit.netralax.de` | ✅ erledigt (End-Zustand) |
| `apps/desktop/.env` | `SUPABASE_URL` + `VITE_SUPABASE_URL` = `https://supabase.netralax.de`; `VITE_LIVEKIT_URL=wss://livekit.netralax.de`; anon-Key + `VITE_VAPID_PUBLIC_KEY` (unverändert übernommen) | ✅ erledigt |
| `apps/mobile/.env.local` | `EXPO_PUBLIC_SUPABASE_URL=https://supabase.netralax.de` (anon-Key, redirect-Schema unverändert) | ✅ erledigt |
| `.env.release` | `UPDATE_HOST=update.netralax.de`, `UPDATE_SSH_USER=chatapp-deploy`, `UPDATE_REMOTE_PATH=/var/www/updates/windows` | ✅ erledigt |
| `package.json` | `release`-Script + `prod:*`-Scripts vorhanden (unverändert; nutzen `scripts/prod/config.sh`) | ✅ vorhanden |
| `apps/desktop/src/lib/changelog.ts` | `CHANGELOG_URL='https://update.netralax.de/windows/changelog.json'` (mit Kommentar, dass alte Clients weiter `.cloud` abfragen) | ✅ erledigt |
> **✅ Erledigt:** Die neue IP `141.95.34.204` ist in `scripts/prod/config.sh` (`PROD_SERVER`) und `scripts/migrate/config.sh` (`NEW_HOST`) eingetragen; Login-User dort ist `debian`.
### 12.2 Neues Desktop-Release bauen + dual publizieren
```bash
# Vom Laptop, mit korrektem .env.release:
pnpm install
pnpm --filter @chat-app/desktop build
pnpm release # = node scripts/release.mjs
```
`scripts/release.mjs` lädt `latest.yml` + `.exe` + aktualisiertes `changelog.json` nach `UPDATE_HOST` (`update.netralax.de`). Da Caddy `update.netralax.de` **und** `update.netralax.cloud` aus demselben Verzeichnis bedient, ist diese eine Veröffentlichung **automatisch** unter beiden Hosts verfügbar (Dual-Publish, Abschnitt 9).
> **🔴 Diese Release MUSS unter `.cloud` erreichbar sein**, denn nur sie schaltet alte Installationen auf `.de` um. Nach dem Upload mit Abschnitt 11.5 verifizieren.
### 12.3 Neues Mobile-Release
```bash
pnpm --filter @chat-app/mobile typecheck
# Expo-Build/Submit nach eurem üblichen EAS-/Store-Prozess.
# .env.local trägt bereits EXPO_PUBLIC_SUPABASE_URL=https://supabase.netralax.de.
```
> Mobile-Clients aktualisieren über die App-Stores, nicht über den Update-Host. Bis ein User die neue Store-Version installiert, hält ihn der `.cloud`-Vhost am Leben.
---
## 13. Rollback-Plan
Der alte VPS bleibt **vollständig intakt und laufend**, bis der neue verifiziert ist. Rollback heißt im Kern: **DNS zurückbiegen**.
1. **Schnell-Rollback (DNS):** Alle `.cloud`-A-Records zurück auf `46.225.156.249` (alte IP), `.de`-Records entfernen oder ebenfalls auf alt zeigen lassen. Dank niedriger TTL (Abschnitt 1.1) greift das in Minuten. Alte Clients landen wieder auf dem alten, intakten Server.
2. **Voraussetzung dafür:** Während der Migration **keine destruktiven Änderungen am alten Server** (alter Stack nicht löschen, alte Volumes nicht anfassen). Der Schreibstopp (Abschnitt 5.1) bedeutet nur Wartungsmodus, kein Datenverlust.
3. **Daten-Divergenz beachten:** Wurden nach dem Cutover bereits Schreibvorgänge auf dem **neuen** Server akzeptiert, gehen diese bei einem reinen DNS-Rollback verloren. Deshalb: Cutover (Abschnitt 10.5, Schreibfreigabe) erst nach den Smoke-Tests; bis dahin ist der Rollback verlustfrei.
4. **Update-Host-Rollback:** `.exe`/`latest.yml` auf dem alten Host wurden nicht verändert; alte Clients, die noch nicht aktualisiert haben, finden dort weiterhin den alten Stand.
5. Wenn nur **eine** Komponente klemmt (z. B. nur TURN ohne Ton), kann punktuell zurückgerollt werden, indem nur der betroffene `.cloud`-Record zurückzeigt die übrigen können auf neu bleiben.
---
## 14. Aufräumen / `.cloud` später abschalten
Die `.cloud`-Hosts dürfen **erst** verschwinden, wenn praktisch keine alten Clients mehr darauf zugreifen.
### 14.1 Reihenfolge der Abschaltung (frühestens → spätestens)
1. **Alten VPS dekommissionieren:** Erst nachdem `.cloud`-DNS auf den **neuen** VPS repointet ist und über die neue IP läuft. (Der alte Server liefert dann ohnehin keinen Traffic mehr.) Vorher als Rollback-Sicherheit behalten (Abschnitt 13).
2. **Supabase-/LiveKit-`.cloud`-Vhosts in Caddy** entfernen, sobald Telemetrie/Logs zeigen, dass praktisch alle aktiven Sessions auf `.de` laufen (d. h. die meisten Desktop-Clients haben die Switch-over-Release gezogen und Mobile-Clients die neue Store-Version).
3. **Update-`.cloud`-Vhost als LETZTES abschalten.**
### 14.2 Warum der Update-Host am längsten bleiben muss
> Eine Desktop-Installation, die **noch nie** die Switch-over-Release gezogen hat, kennt **nur** `update.netralax.cloud` (eingebacken). Sie erreicht `.de` ausschließlich, indem sie die neue Version über **`.cloud`** herunterlädt. Schaltest du `update.netralax.cloud` zu früh ab, **stranden** alle noch nicht aktualisierten Clients dauerhaft auf der alten Version sie können sich nie mehr selbst auf `.de` updaten und müssten manuell neu installiert werden.
>
> Faustregel: `update.netralax.cloud` so lange behalten, bis die Update-Metriken zeigen, dass der Long-Tail alter Installationen vernachlässigbar ist (eher Monate als Wochen). Supabase-/LiveKit-`.cloud` können früher fallen als Update-`.cloud`, aber niemals umgekehrt.
### 14.3 Endzustand
- DNS: nur noch `*.netralax.de` aktiv; `*.netralax.cloud` entfernt (zuletzt `update.netralax.cloud`).
- Caddyfile: nur noch die `.de`-Vhosts (Legacy-Block + Kommentar entfernt).
- `scripts/prod/config.sh` ist die alleinige Live-Konfiguration; `scripts/migrate/` wird nicht mehr gebraucht (kann archiviert bleiben).
- Lokale Sicherungen (`old.env.backup`, `_storage_stage/`, `_updates_stage/`) sicher löschen (`shred`/Secure-Delete), da sie Secrets enthalten.
---
**Grounding-Hinweise (Repo-Fakten):** Postgres-Major-Version aus `supabase/config.toml` = `17`. Edge-Functions im Repo: `supabase/functions/{mint-livekit-token,notify-push,og-preview}`. Dev-`infra/livekit/livekit.yaml` enthält absichtlich `use_external_ip: false` + `node_ip: 127.0.0.1` (Dev-only — in Prod invertiert/entfernt). `apps/desktop/.env`, `apps/mobile/.env.local`, `.env.release`, `scripts/prod/config.sh` und `apps/desktop/src/lib/changelog.ts` sind bereits auf `.de` umgestellt (verifiziert).