diff --git a/.env.example b/.env.example index 9d128a5..f0b861c 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ SESSION_COOKIE_SECRET=please-change-me-in-prod-at-least-32-chars # --- Cron Bearer (Story 2.6 + 5.1 + 6.3) --- CRON_BEARER_TOKEN=please-change-me-in-prod +# --- Backup-Sidecar (Story 6.3, nur Prod) --- +# Täglicher pg_dump im service "backup". Beide optional (Defaults: 03 / 14). +# BACKUP_HOUR=03 +# BACKUP_RETENTION_DAYS=14 + # --- App --- NODE_ENV=development LOG_LEVEL=info diff --git a/docker-compose.yml b/docker-compose.yml index 0868227..42bcb8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,33 @@ services: timeout: 5s retries: 5 + # Backup-Sidecar: täglicher pg_dump des postgres-Service nach cockpit_backups. + # Nutzt scripts/backup.sh (Custom-Format-Dump + Retention + API-Heartbeat), + # getaktet von scripts/backup-loop.sh. Stock postgres-Image → pg_dump passt + # versionsgenau (16) zur DB. Restore: siehe docs/operations.md. + backup: + image: postgres:16-alpine + container_name: cockpit-backup + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + POSTGRES_USER: ${POSTGRES_USER:-cockpit} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-cockpit} + PGHOST: postgres + API_URL: http://api:3000 + CRON_BEARER_TOKEN: ${CRON_BEARER_TOKEN} + BACKUP_HOUR: ${BACKUP_HOUR:-03} + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14} + TZ: Europe/Berlin + volumes: + - cockpit_backups:/backups + - ./scripts/backup.sh:/usr/local/bin/backup.sh:ro + - ./scripts/backup-loop.sh:/usr/local/bin/backup-loop.sh:ro + entrypoint: ['/bin/sh', '/usr/local/bin/backup-loop.sh'] + api: build: context: . diff --git a/docs/operations.md b/docs/operations.md index c4e1aa9..2c4116f 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -54,14 +54,35 @@ docker compose exec api node -e "import('@cockpit/db/client').then(async ({ crea ## Backups (Story 6.3) -Tägliches `pg_dump` via Cron-Endpoint `POST /internal/cron/pg-dump-backup` mit Bearer-Token. -14 Tage Retention. Restore-Drill quartalsweise. +Tägliches `pg_dump` läuft im **Backup-Sidecar** (`docker-compose.yml` service `backup`): +`scripts/backup-loop.sh` triggert `scripts/backup.sh` einmal pro Tag um +`BACKUP_HOUR` (Default 03:00 Europe/Berlin). Dump im **Custom-Format** (`-Fc`, +bereits komprimiert) nach `cockpit_backups:/backups` als `cockpit-.dump`. +Retention `BACKUP_RETENTION_DAYS` (Default 14). Nach jedem Lauf ein Heartbeat an +`POST /internal/cron/pg-dump-backup` (nur Logging — der Endpoint macht **kein** +Backup). Restore-Drill quartalsweise. + +Konfiguration (optional, in `.env.production`): `BACKUP_HOUR`, `BACKUP_RETENTION_DAYS`. + +```bash +# Status / letztes Backup ansehen +docker compose logs --tail=20 backup +docker compose exec backup ls -lh /backups + +# Sofort ein Backup auslösen (außer der Reihe) +docker compose exec backup /usr/local/bin/backup.sh +``` ### Restore-Anleitung 1. API stoppen: `docker compose stop api` -2. Backup aus `/backups` auswählen. -3. Restore in temporäre DB: `docker compose exec postgres pg_restore -U cockpit -d cockpit_restore /backups/.sql.gz` +2. Backup aus `/backups` auswählen (`docker compose exec postgres ls -lh /backups`). +3. Restore in temporäre DB: + ```bash + docker compose exec postgres createdb -U cockpit cockpit_restore + docker compose exec postgres pg_restore -U cockpit -d cockpit_restore /backups/.dump + ``` + (In die Produktiv-DB stattdessen mit `--clean --if-exists -d cockpit`.) 4. Smoke-Test: `curl http://localhost:3000/api/health` 5. API wieder starten: `docker compose start api` diff --git a/scripts/backup-loop.sh b/scripts/backup-loop.sh new file mode 100755 index 0000000..2cab1bd --- /dev/null +++ b/scripts/backup-loop.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Minimaler Tages-Scheduler für den Backup-Sidecar: führt backup.sh einmal +# täglich zur Stunde BACKUP_HOUR (Container-TZ = Europe/Berlin) aus. +# +# Bewusst kein cron: ein simpler Poll-Loop erbt das Container-Environment +# zuverlässig (POSTGRES_PASSWORD u. a.) und hat keine busybox-cron-Eigenheiten. + +set -eu + +HOUR="${BACKUP_HOUR:-03}" +echo "Backup-Sidecar aktiv — täglich um ${HOUR}:00 (TZ=${TZ:-UTC}), Retention ${BACKUP_RETENTION_DAYS:-14} Tage." + +while true; do + if [ "$(date +%H)" = "$HOUR" ] && [ "$(date +%M)" = "00" ]; then + /usr/local/bin/backup.sh || echo "Backup fehlgeschlagen (Exit $?)" >&2 + sleep 61 # diese Minute überspringen, damit nicht doppelt ausgelöst wird + fi + sleep 30 +done diff --git a/scripts/backup.sh b/scripts/backup.sh index 0c04f43..c47f5ad 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,21 +1,31 @@ #!/bin/sh -# Tägliches pg_dump in /backups, 14 Tage Retention. -# Läuft im Postgres-Container via System-Cron. +# pg_dump des Cockpit-Postgres nach /backups. Custom-Format (-Fc): bereits +# komprimiert UND erlaubt selektives pg_restore — daher KEIN zusätzliches gzip. +# +# Läuft im Backup-Sidecar (docker-compose.yml service "backup") gegen den +# postgres-Service (PGHOST). Einmal pro Tag getriggert von backup-loop.sh. +# Manuell ausführbar: `docker compose exec backup /usr/local/bin/backup.sh`. set -eu +PGHOST="${PGHOST:-postgres}" +PGUSER="${POSTGRES_USER:-cockpit}" +PGDATABASE="${POSTGRES_DB:-cockpit}" +RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" DATE=$(date +%Y%m%d-%H%M%S) -OUT="/backups/cockpit-${DATE}.sql.gz" +OUT="/backups/cockpit-${DATE}.dump" mkdir -p /backups -pg_dump -U "${POSTGRES_USER:-cockpit}" -d "${POSTGRES_DB:-cockpit}" -Fc | gzip > "$OUT" -echo "Backup written: $OUT" +export PGPASSWORD="${POSTGRES_PASSWORD:-}" +pg_dump -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -Fc -f "$OUT" +echo "Backup geschrieben: $OUT ($(du -h "$OUT" | cut -f1))" -# Retention: lösche Dumps älter als 14 Tage. -find /backups -type f -name "cockpit-*.sql.gz" -mtime +14 -delete +# Retention: Dumps älter als N Tage löschen. +find /backups -type f -name 'cockpit-*.dump' -mtime "+${RETENTION_DAYS}" -delete -# Heartbeat an API (logging). -if [ -n "${CRON_BEARER_TOKEN:-}" ] && [ -n "${API_URL:-}" ]; then +# Heartbeat an die API (nur Observability/Logging — macht selbst kein Backup). +API_URL="${API_URL:-http://api:3000}" +if [ -n "${CRON_BEARER_TOKEN:-}" ]; then wget -q --header="Authorization: Bearer ${CRON_BEARER_TOKEN}" \ -O- "${API_URL}/internal/cron/pg-dump-backup" || true fi