Ops: add Proxmox migration tooling and runbook

Add end-to-end migration scripts for inventory, precopy, cutover, smoke tests, rollback, and post-migration checks. Include an operational runbook and Proxmox env template to move Hördle behind Nginx Proxy Manager while preserving persistent volumes safely.
This commit is contained in:
Hördle Bot
2026-04-25 09:46:52 +00:00
parent e58e9156d6
commit 1c7bfdf421
10 changed files with 607 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# ============================================
# Hördle Environment (Proxmox + NPM)
# ============================================
# Kopiere nach .env und passe alle Werte an.
# Build-Time
NEXT_PUBLIC_APP_NAME=Hördle
NEXT_PUBLIC_APP_DESCRIPTION=Daily music guessing game
NEXT_PUBLIC_DOMAIN=hoerdle.de
NEXT_PUBLIC_TWITTER_HANDLE=@hoerdle
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=hoerdle.de
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.example.com/js/script.js
NEXT_PUBLIC_THEME_COLOR=#000000
NEXT_PUBLIC_BACKGROUND_COLOR=#ffffff
NEXT_PUBLIC_CREDITS_ENABLED=true
NEXT_PUBLIC_CREDITS_TEXT=Vibe coded with coffee
NEXT_PUBLIC_CREDITS_LINK_TEXT=@yourhandle@server.social
NEXT_PUBLIC_CREDITS_LINK_URL=https://server.social/@yourhandle
# Runtime
DATABASE_URL=file:/app/data/prod.db
ADMIN_PASSWORD=<BCRYPT_HASH_HERE>
TZ=Europe/Berlin
GOTIFY_URL=
GOTIFY_APP_TOKEN=
OPENROUTER_API_KEY=
# Optional Build Metadata
# APP_VERSION=v1.0.0
+121
View File
@@ -0,0 +1,121 @@
# Hördle Migration Runbook (Proxmox-LXC + Nginx Proxy Manager)
Dieses Runbook setzt den Migrationsplan operativ um und priorisiert Datensicherheit
fuer `data/prod.db` und `public/uploads`.
## 0) Voraussetzungen
- Quelle (alt): VPS mit laufendem Hördle-Compose-Stack.
- Ziel (neu): Proxmox-LXC unter `root@10.0.0.19`.
- DNS ist bereits vorbereitet.
- Ziel-LXC ist im internen Netz vom NPM-LXC erreichbar.
## 1) Inventur auf der Quelle
```bash
cd /home/docker/hoerdle
./scripts/migration-inventory.sh
```
Erwartung:
- Persistenzpfade vorhanden: `data`, `data/prod.db`, `public/uploads`
- Runtime-Variablen in `docker-compose.yml` referenzieren `.env`
## 2) Ziel-LXC vorbereiten
Auf dem Ziel-LXC (`root@10.0.0.19`):
```bash
mkdir -p /opt/hoerdle
# Repo nach /opt/hoerdle bringen (git clone oder rsync)
cd /opt/hoerdle
./scripts/migration-prepare-target.sh
```
Danach:
- `.env` in `/opt/hoerdle/.env` produktiv ausfuellen
- `ADMIN_PASSWORD` als bcrypt hash setzen
- Caddy-Stack nicht verwenden (nur `docker-compose.yml`)
## 3) Vorabkopie der Daten (Quelle -> Ziel)
Auf der Quelle:
```bash
cd /home/docker/hoerdle
TARGET_HOST=root@10.0.0.19 TARGET_APP_DIR=/opt/hoerdle ./scripts/migration-precopy.sh
```
Das Skript synchronisiert:
- `data/`
- `public/uploads/`
und zeigt Dateizaehlung/Groessen als Plausibilitaetscheck.
## 4) Cutover im Wartungsfenster
Auf der Quelle:
```bash
cd /home/docker/hoerdle
TARGET_HOST=root@10.0.0.19 TARGET_APP_DIR=/opt/hoerdle ./scripts/migration-cutover.sh
```
Das Skript:
1. stoppt die Quell-App,
2. fuehrt Final-Sync aus,
3. verifiziert `prod.db` per SHA256,
4. startet die Ziel-App mit `docker compose up -d --build`.
## 5) NPM umschalten + Smoke-Test
Im Nginx Proxy Manager:
- Proxy Host auf `10.0.0.19:3010` setzen
- Scheme `http`, Websocket Support aktiv
- SSL aktivieren (Let's Encrypt), Force SSL einschalten
- Header-Weiterleitung aktiv lassen (`X-Forwarded-*`)
Smoke-Test:
```bash
./scripts/migration-smoke-test.sh https://hoerdle.de
```
Zusatzpruefungen manuell:
- Admin-Login
- Upload eines Testfiles
- Abruf des Uploads im Frontend
## 6) Post-Migration (24h)
- `docker compose logs --since=24h` auf Ziel auswerten
- NPM Access/Error Logs pruefen
- taegliches Backup sicherstellen:
- `data/prod.db`
- `public/uploads/`
- Alte Instanz erst nach stabiler Beobachtungszeit stilllegen
Automatisierter Postcheck:
```bash
cd /opt/hoerdle
DOMAIN_URL=https://hoerdle.de ./scripts/migration-postcheck.sh
```
Beispiel fuer taegliches Backup (cron auf Ziel-LXC):
```bash
crontab -e
# taeglich 03:30
30 3 * * * cd /opt/hoerdle && ./scripts/backup-persistence.sh >> /opt/hoerdle/logs/backup-persistence.log 2>&1
```
## 7) Rollback
Wenn Tests fehlschlagen:
```bash
TARGET_HOST=root@10.0.0.19 TARGET_APP_DIR=/opt/hoerdle ./scripts/migration-rollback.sh
```
Danach sofort im NPM auf alte Instanz zurueckschalten.
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
# Erstellt ein lokales Backup der Persistenzdaten.
# Ziel: data/prod.db + public/uploads
APP_DIR="${APP_DIR:-$(pwd)}"
BACKUP_DIR="${BACKUP_DIR:-$APP_DIR/backups/persistence}"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
OUT_DIR="$BACKUP_DIR/$TIMESTAMP"
cd "$APP_DIR"
mkdir -p "$OUT_DIR"
echo "== Hördle Persistenz-Backup =="
echo "Quelle: $APP_DIR"
echo "Ziel: $OUT_DIR"
if [ -f "data/prod.db" ]; then
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "data/prod.db" ".timeout 5000" ".backup '$OUT_DIR/prod.db'"
else
cp "data/prod.db" "$OUT_DIR/prod.db"
fi
sha256sum "$OUT_DIR/prod.db" > "$OUT_DIR/prod.db.sha256"
else
echo "Warnung: data/prod.db fehlt."
fi
if [ -d "public/uploads" ]; then
tar -C "public" -czf "$OUT_DIR/uploads.tar.gz" "uploads"
sha256sum "$OUT_DIR/uploads.tar.gz" > "$OUT_DIR/uploads.tar.gz.sha256"
else
echo "Warnung: public/uploads fehlt."
fi
echo "$TIMESTAMP" > "$BACKUP_DIR/latest"
echo "Backup abgeschlossen: $OUT_DIR"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
# Cutover-Skript fuer kurzes Wartungsfenster.
# Auf QUELLSYSTEM ausfuehren:
# 1) stoppt lokale Hördle-App
# 2) finaler Delta-Sync data/ + uploads/
# 3) startet Ziel-App im Proxmox-LXC
TARGET_HOST="${TARGET_HOST:-root@10.0.0.19}"
TARGET_APP_DIR="${TARGET_APP_DIR:-/opt/hoerdle}"
SOURCE_APP_DIR="${SOURCE_APP_DIR:-$(pwd)}"
SOURCE_COMPOSE_FILE="${SOURCE_COMPOSE_FILE:-docker-compose.yml}"
TARGET_COMPOSE_FILE="${TARGET_COMPOSE_FILE:-docker-compose.yml}"
SSH_OPTS="${SSH_OPTS:-}"
cd "$SOURCE_APP_DIR"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Fehlend: $1"
exit 1
fi
}
require_cmd docker
require_cmd rsync
require_cmd ssh
require_cmd sha256sum
echo "== Hördle Cutover =="
echo "Quelle: $(pwd)"
echo "Ziel: $TARGET_HOST:$TARGET_APP_DIR"
echo
echo "Wartungsfenster starten. Enter zum Fortfahren, Ctrl+C zum Abbruch."
read -r
echo "-- Quelle stoppen --"
docker compose -f "$SOURCE_COMPOSE_FILE" down
echo
echo "-- Finaler Delta-Sync data/ --"
rsync -aHAX --numeric-ids --delete \
-e "ssh $SSH_OPTS" \
"./data/" "$TARGET_HOST:$TARGET_APP_DIR/data/"
echo
echo "-- Finaler Delta-Sync public/uploads/ --"
rsync -aHAX --numeric-ids --delete \
-e "ssh $SSH_OPTS" \
"./public/uploads/" "$TARGET_HOST:$TARGET_APP_DIR/public/uploads/"
echo
if [ -f "./data/prod.db" ]; then
echo "-- Konsistenzcheck DB --"
LOCAL_HASH="$(sha256sum ./data/prod.db | awk '{print $1}')"
REMOTE_HASH="$(ssh $SSH_OPTS "$TARGET_HOST" "sha256sum '$TARGET_APP_DIR/data/prod.db' | awk '{print \$1}'")"
echo "local prod.db sha256: $LOCAL_HASH"
echo "remote prod.db sha256: $REMOTE_HASH"
if [ "$LOCAL_HASH" != "$REMOTE_HASH" ]; then
echo "DB Hash mismatch. Abbruch vor Start auf Ziel."
exit 1
fi
fi
echo
echo "-- Ziel-App starten --"
ssh $SSH_OPTS "$TARGET_HOST" "cd '$TARGET_APP_DIR' && docker compose -f '$TARGET_COMPOSE_FILE' up -d --build"
echo
echo "Cutover abgeschlossen. Bitte jetzt scripts/migration-smoke-test.sh ausfuehren."
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
# Inventarisiert Persistenz und laufzeitrelevante Variablen für die Migration.
# Das Skript verändert keine Daten.
ROOT_DIR="${1:-$(pwd)}"
cd "$ROOT_DIR"
count_files_recursive() {
local dir="$1"
find "$dir" -type f 2>/dev/null | wc -l
}
echo "== Hördle Migrations-Inventur =="
echo "Projektpfad: $(pwd)"
echo
required_paths=(
"data"
"data/prod.db"
"public/uploads"
"docker-compose.yml"
".env"
)
echo "-- Existenzcheck --"
for path in "${required_paths[@]}"; do
if [ -e "$path" ]; then
echo "[OK] $path"
else
echo "[FEHLT] $path"
fi
done
echo
echo "-- Groesse und Dateizaehlung --"
if [ -d "data" ]; then
du -sh "data"
DATA_FILE_COUNT="$(count_files_recursive "data")"
echo "Dateien in data/: $DATA_FILE_COUNT"
fi
if [ -d "public/uploads" ]; then
du -sh "public/uploads"
UPLOAD_FILE_COUNT="$(count_files_recursive "public/uploads")"
echo "Dateien in public/uploads/: $UPLOAD_FILE_COUNT"
fi
echo
echo "-- DB Hash (Integritaetsreferenz) --"
if [ -f "data/prod.db" ]; then
sha256sum "data/prod.db"
else
echo "data/prod.db nicht gefunden."
fi
echo
echo "-- Compose Persistenz-Mounts --"
grep -E "^[[:space:]]*-[[:space:]]+\./(data|public/uploads):" "docker-compose.yml" || true
echo
echo "-- Runtime Variablen in Compose --"
grep -E "^[[:space:]]*-[[:space:]]+(DATABASE_URL|ADMIN_PASSWORD|TZ|GOTIFY_URL|GOTIFY_APP_TOKEN|OPENROUTER_API_KEY)=" "docker-compose.yml" || true
echo
echo "-- Hinweise --"
echo "1) Stelle sicher, dass .env nicht aus Versehen in Transfers/Backups an Dritte gelangt."
echo "2) Fuer den Cutover muss die Quell-App vor Final-Sync gestoppt werden."
echo "3) Nutze danach scripts/migration-precopy.sh und scripts/migration-cutover.sh."
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
# Kontrollskript fuer die ersten 24h nach Migration.
APP_DIR="${APP_DIR:-$(pwd)}"
DOMAIN_URL="${DOMAIN_URL:-https://hoerdle.de}"
CONTAINER_NAME="${CONTAINER_NAME:-hoerdle}"
cd "$APP_DIR"
echo "== Hördle Post-Migration Check =="
echo "App dir: $APP_DIR"
echo "Domain: $DOMAIN_URL"
echo
echo "-- Container Status --"
docker compose ps
echo
echo "-- Health Endpoint --"
curl -fsS "$DOMAIN_URL/api/daily" >/dev/null
echo "OK: /api/daily erreichbar"
echo
echo "-- Fehlerlogs (24h) --"
docker compose logs --since=24h "$CONTAINER_NAME" 2>&1 | grep -Ei "(error|exception|fatal|panic)" || true
echo
echo "-- Backup Testlauf --"
./scripts/backup-persistence.sh
echo
echo "Postcheck abgeschlossen."
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
# Vorabkopie der persistenten Daten von Quelle -> Ziel (App bleibt online).
# Auf dem Quellsystem ausfuehren.
TARGET_HOST="${TARGET_HOST:-root@10.0.0.19}"
TARGET_APP_DIR="${TARGET_APP_DIR:-/opt/hoerdle}"
SOURCE_APP_DIR="${SOURCE_APP_DIR:-$(pwd)}"
SSH_OPTS="${SSH_OPTS:-}"
cd "$SOURCE_APP_DIR"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Fehlend: $1"
exit 1
fi
}
require_cmd rsync
require_cmd ssh
require_cmd sha256sum
require_cmd find
echo "== Hördle Precopy =="
echo "Quelle: $(pwd)"
echo "Ziel: $TARGET_HOST:$TARGET_APP_DIR"
echo
for d in data public/uploads; do
if [ ! -d "$d" ]; then
echo "Pfad fehlt: $d"
exit 1
fi
done
echo "-- Zielordner sicherstellen --"
ssh $SSH_OPTS "$TARGET_HOST" "mkdir -p '$TARGET_APP_DIR/data' '$TARGET_APP_DIR/public/uploads'"
echo
echo "-- Vorabkopie data/ --"
rsync -aHAX --numeric-ids --delete \
-e "ssh $SSH_OPTS" \
"./data/" "$TARGET_HOST:$TARGET_APP_DIR/data/"
echo
echo "-- Vorabkopie public/uploads/ --"
rsync -aHAX --numeric-ids --delete \
-e "ssh $SSH_OPTS" \
"./public/uploads/" "$TARGET_HOST:$TARGET_APP_DIR/public/uploads/"
echo
echo "-- Verifikation (Dateien/Groesse) --"
LOCAL_DATA_COUNT="$(find data -type f 2>/dev/null | wc -l)"
REMOTE_DATA_COUNT="$(ssh $SSH_OPTS "$TARGET_HOST" "cd '$TARGET_APP_DIR' && find data -type f 2>/dev/null | wc -l")"
echo "data files local=$LOCAL_DATA_COUNT remote=$REMOTE_DATA_COUNT"
LOCAL_UPLOAD_COUNT="$(find public/uploads -type f 2>/dev/null | wc -l)"
REMOTE_UPLOAD_COUNT="$(ssh $SSH_OPTS "$TARGET_HOST" "cd '$TARGET_APP_DIR' && find public/uploads -type f 2>/dev/null | wc -l")"
echo "uploads files local=$LOCAL_UPLOAD_COUNT remote=$REMOTE_UPLOAD_COUNT"
LOCAL_DATA_SIZE="$(du -sb data | awk '{print $1}')"
REMOTE_DATA_SIZE="$(ssh $SSH_OPTS "$TARGET_HOST" "du -sb '$TARGET_APP_DIR/data' | awk '{print \$1}'")"
echo "data bytes local=$LOCAL_DATA_SIZE remote=$REMOTE_DATA_SIZE"
LOCAL_UPLOAD_SIZE="$(du -sb public/uploads | awk '{print $1}')"
REMOTE_UPLOAD_SIZE="$(ssh $SSH_OPTS "$TARGET_HOST" "du -sb '$TARGET_APP_DIR/public/uploads' | awk '{print \$1}'")"
echo "uploads bytes local=$LOCAL_UPLOAD_SIZE remote=$REMOTE_UPLOAD_SIZE"
echo
if [ -f "data/prod.db" ]; then
echo "-- DB Hash Vergleich (Precopy) --"
LOCAL_HASH="$(sha256sum data/prod.db | awk '{print $1}')"
REMOTE_HASH="$(ssh $SSH_OPTS "$TARGET_HOST" "sha256sum '$TARGET_APP_DIR/data/prod.db' | awk '{print \$1}'")"
echo "prod.db sha256 local=$LOCAL_HASH"
echo "prod.db sha256 remote=$REMOTE_HASH"
fi
echo
echo "Precopy abgeschlossen. Fuer konsistente Enddaten jetzt Cutover ausfuehren."
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
# Dieses Skript auf dem ZIEL-LXC ausfuehren.
# Es richtet die Ordnerstruktur und Preflight-Checks fuer den Hördle-Betrieb ein.
APP_DIR="${APP_DIR:-/opt/hoerdle}"
APP_USER="${APP_USER:-docker}"
APP_GROUP="${APP_GROUP:-docker}"
echo "== Prepare target LXC for Hördle =="
echo "APP_DIR=$APP_DIR"
echo "APP_USER=$APP_USER"
echo
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Fehlend: $1"
exit 1
fi
}
require_cmd docker
if ! docker compose version >/dev/null 2>&1; then
echo "docker compose Plugin fehlt."
exit 1
fi
echo "-- Docker Version --"
docker --version
docker compose version
echo
echo "-- Verzeichnisse vorbereiten --"
mkdir -p "$APP_DIR"/{data,public/uploads,logs,backups}
chown -R "$APP_USER:$APP_GROUP" "$APP_DIR"
chmod 755 "$APP_DIR"
chmod 755 "$APP_DIR/data" "$APP_DIR/public" "$APP_DIR/public/uploads"
echo "Ordner vorbereitet."
echo
echo "-- Speichercheck --"
df -h "$APP_DIR"
echo
echo "-- Netzwerkcheck --"
if ! docker network inspect hoerdle_default >/dev/null 2>&1; then
echo "Docker-Netzwerk hoerdle_default fehlt, wird erstellt."
docker network create hoerdle_default >/dev/null
fi
echo "Netzwerk hoerdle_default bereit."
echo
echo "-- .env Vorlage --"
if [ ! -f "$APP_DIR/.env" ]; then
if [ -f "$APP_DIR/.env.proxmox.example" ]; then
cp "$APP_DIR/.env.proxmox.example" "$APP_DIR/.env"
echo ".env aus .env.proxmox.example erstellt."
elif [ -f "$APP_DIR/.env.example" ]; then
cp "$APP_DIR/.env.example" "$APP_DIR/.env"
echo ".env aus .env.example erstellt."
else
echo "Keine Env-Vorlage gefunden. Bitte .env manuell erstellen."
fi
else
echo ".env existiert bereits, kein Override."
fi
echo
echo "-- Abschluss --"
echo "Target-LXC vorbereitet. Nächster Schritt: Daten-Vorabkopie aus der Quelle."
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
# Rollback-Hilfsskript: stoppt Ziel-Container und zeigt klare Operator-Hinweise.
# Die eigentliche Rückleitung des Traffics erfolgt im Nginx Proxy Manager.
TARGET_HOST="${TARGET_HOST:-root@10.0.0.19}"
TARGET_APP_DIR="${TARGET_APP_DIR:-/opt/hoerdle}"
TARGET_COMPOSE_FILE="${TARGET_COMPOSE_FILE:-docker-compose.yml}"
SSH_OPTS="${SSH_OPTS:-}"
echo "== Hördle Rollback-Hilfe =="
echo "Zielhost: $TARGET_HOST"
echo
echo "-- Ziel-App stoppen --"
ssh $SSH_OPTS "$TARGET_HOST" "cd '$TARGET_APP_DIR' && docker compose -f '$TARGET_COMPOSE_FILE' down"
echo
echo "Naechste Schritte:"
echo "1) NPM Proxy Host sofort wieder auf alte VPS-Instanz umstellen."
echo "2) Externen Smoke-Test auf alter Instanz durchfuehren."
echo "3) Fehleranalyse im Ziel-LXC (docker compose logs)."
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Smoke-Tests nach dem Umschalten auf Proxmox/NPM.
# Kann lokal oder auf beliebigem Host mit curl ausgefuehrt werden.
BASE_URL="${1:-https://hoerdle.de}"
EXPECTED_STATUS="${EXPECTED_STATUS:-200}"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Fehlend: $1"
exit 1
fi
}
require_cmd curl
require_cmd grep
echo "== Hördle Smoke-Test =="
echo "BASE_URL=$BASE_URL"
echo
check_http() {
local name="$1"
local url="$2"
local code
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
if [ "$code" = "$EXPECTED_STATUS" ]; then
echo "[OK] $name -> $code"
else
echo "[FAIL] $name -> $code (erwartet: $EXPECTED_STATUS)"
return 1
fi
}
check_body_contains() {
local name="$1"
local url="$2"
local needle="$3"
if curl -fsS "$url" | grep -q "$needle"; then
echo "[OK] $name enthält '$needle'"
else
echo "[FAIL] $name enthält '$needle' nicht"
return 1
fi
}
FAILED=0
check_http "Homepage" "$BASE_URL/" || FAILED=1
check_http "Daily API" "$BASE_URL/api/daily" || FAILED=1
# Prüft, ob Daily API als JSON zurückkommt.
check_body_contains "Daily API" "$BASE_URL/api/daily" "{" || FAILED=1
echo
if [ "$FAILED" -eq 0 ]; then
echo "Smoke-Test erfolgreich."
else
echo "Smoke-Test fehlgeschlagen."
exit 1
fi