Appearance
Backup & Disaster Recovery
Prinzip
Drei voneinander unabhängige Schichten:
- Stack-Definitionen liegen in Git (
matev/server-stacksauf Forgejo + Mirror auf einem zweiten Forgejo oder GitHub-Privat). - Datenbanken werden vor dem Volume-Backup per
mysqldump/pg_dumpin einendumps/-Ordner pro Stack geschrieben. - Volumes (alle
*_data/,*_storage/,cms_content/,cms_media/, plus die Dump-Ordner) werden mit Restic auf eine NAS oder einen Backup-Server gepushed.
Recovery aus dem Vollausfall:
git clone server-stacks → restic restore Volumes → docker compose up -dZeit bis "wieder oben": ca. 30-60 Min wenn die Restic-Quelle erreichbar ist.
Was wo liegt
Zwei Verzeichnis-Bäume zu sichern:
home/bengel/stacks/ ← Compose + persistente Daten-Volumes
└── proxy/, mgmt/, git/, apps/
/var/www/ ← Source-Code + storage/content/media
└── admin/, cms/, b2b/, staging/, docs/, storybook/, ...| Stack / Pfad | Was sichern |
|---|---|
~/stacks/proxy/ | Docker-Volumes caddy_data, caddy_config (oder als Bind-Mount auf ~/stacks/proxy/caddy_data/) |
~/stacks/mgmt/ | portainer_data/, matomo_data/, matomo_db_data/, dumps/ |
~/stacks/git/ | postgres_forgejo/, forgejo_data/, forgejo_config/, woodpecker_data/, dumps/ |
~/stacks/apps/ | apps_db_data/, meili_data/, opensearch_data/, ollama_data/, penpot_postgres/, penpot_assets/, dumps/ |
/var/www/admin/storage/ | Filament-Uploads + Logs |
/var/www/cms/content/ + media/ + site/accounts/ | Kirby-Inhalt (kritisch — der CMS-Content!) |
/var/www/b2b/storage/ | Livewire-Uploads |
/var/www/<service>/.env | Konfig-Secrets pro Service (oder separat im PW-Manager halten) |
apps_redis_data/ wird nicht gesichert (rein flüchtige Cache/Queue-Daten). ollama_data/ ist optional — die Modelle lassen sich neu pullen, aber Wiederherstellung ist schneller mit Backup.
Datenbank-Dumps (vor Volume-Backup)
~/stacks/db-backup.sh (gemeinsames Script für alle Stacks):
bash
#!/usr/bin/env bash
set -euo pipefail
STAMP=$(date +%Y-%m-%d_%H%M)
KEEP_DAYS=14
dump() { # dump <stack> <type> <container> <db> <user> <pass>
local stack=$1 type=$2 container=$3 db=$4 user=$5 pass=$6
local out=~/stacks/$stack/dumps/${db}-${STAMP}.sql.gz
mkdir -p "$(dirname "$out")"
case "$type" in
mariadb) docker exec "$container" \
mariadb-dump --single-transaction --quick --lock-tables=false \
-u "$user" -p"$pass" "$db" | gzip > "$out" ;;
mysql) docker exec "$container" \
mysqldump --single-transaction --quick --lock-tables=false \
-u "$user" -p"$pass" "$db" | gzip > "$out" ;;
pg) docker exec "$container" \
pg_dump -U "$user" -d "$db" | gzip > "$out" ;;
esac
# alte Dumps wegräumen
find ~/stacks/$stack/dumps -name "${db}-*.sql.gz" -mtime +$KEEP_DAYS -delete
}
# mgmt
dump mgmt mariadb matomo-db matomo matomo "$MATOMO_DB_PASSWORD"
# git
dump git pg forgejo-db forgejo forgejo "$FORGEJO_DB_PASSWORD"
# apps
dump apps mariadb apps-db matev_admin matev "$APPS_DB_PASSWORD"
dump apps mariadb apps-db matev_b2b matev "$APPS_DB_PASSWORD"
dump apps pg penpot-postgres penpot penpot "$PENPOT_DB_PASSWORD"
# taiga
dump taiga pg taiga-db taiga taiga "$TAIGA_DB_PASSWORD"Cron (root):
30 2 * * * /home/bengel/stacks/db-backup.sh >> /var/log/matev-db-backup.log 2>&1Volume-Backup mit Restic
Restic ist deduplizierend, verschlüsselt, inkrementell — perfekt für tägliche Snapshots.
Setup (einmalig)
bash
# Restic installieren
apt install -y restic
# Repo initialisieren (auf NAS via SFTP)
export RESTIC_REPOSITORY="sftp:backup@nas.matev.eu:/volume1/backup/matev"
export RESTIC_PASSWORD_FILE=/root/.restic-password
echo "<langes-zufaelliges-passwort>" > /root/.restic-password
chmod 600 /root/.restic-password
restic initAlternativ: B2 / S3 / Hetzner-Storage-Box als Repo.
Tägliches Backup-Script
/usr/local/sbin/matev-backup.sh:
bash
#!/usr/bin/env bash
set -euo pipefail
export RESTIC_REPOSITORY="sftp:backup@nas.matev.eu:/volume1/backup/matev"
export RESTIC_PASSWORD_FILE=/root/.restic-password
# Snapshot beider Hierarchien — stacks/ (Compose+Daten) und /var/www/ (Code+Storage)
restic backup /home/bengel/stacks /var/www \
--tag daily \
--exclude='*/apps_redis_data/*' \
--exclude='*/woodpecker_data/build_*' \
--exclude='*/node_modules/*' \
--exclude='*/vendor/*' \
--exclude='*.tmp'
# Retention: 7 daily, 4 weekly, 6 monthly, 2 yearly
restic forget --prune \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6 \
--keep-yearly 2
# Integrität prüfen (1× pro Woche, hier täglich Stichprobe)
restic check --read-data-subset=2%Cron (root, nach dem DB-Dump):
0 3 * * * /usr/local/sbin/matev-backup.sh >> /var/log/matev-backup.log 2>&1Caddy-Volumes
caddy_data + caddy_config sind benannte Docker-Volumes (kein Bind-Mount im stacks/-Verzeichnis). Entweder:
- Variante A (empfohlen): die Volumes in
proxy/docker-compose.ymlauf Bind-Mounts umstellen —./caddy_data:/data,./caddy_config:/config— dann sind sie automatisch im Restic-Snapshot drin. - Variante B: Restic-Backup zusätzlich auf
/var/lib/docker/volumes/ansetzen (umständlicher, läuft als root, fragiler).
Recovery-Test
Mindestens monatlich auf einer anderen Maschine durchspielen — sonst ist das Backup nicht beweisbar:
bash
# Auf einem Test-Host:
restic snapshots
restic restore latest --target /tmp/restore-test
ls /tmp/restore-test/home/bengel/stacks/apps/ # sollte alle Volumes zeigenDisaster-Recovery-Pfad
Server brennt ab. Neuer Server steht. Wiederherstellung:
bash
# 1) Basis-Setup (Docker, Compose, gateway-Netz)
curl -fsSL https://get.docker.com | sh
docker network create gateway
# 2) Stack-Definitionen aus Git holen
mkdir -p /home/bengel/stacks
cd /home/bengel/stacks
git clone https://git.matev.eu/matev/server-stacks.git .
# Wenn Forgejo gerade auch hops ist: vom GitHub-Mirror
# 3) Stacks + Webroots aus Restic restoren
export RESTIC_REPOSITORY="sftp:backup@nas.matev.eu:/volume1/backup/matev"
export RESTIC_PASSWORD_FILE=/root/.restic-password
restic restore latest --target / --include /home/bengel/stacks --include /var/www
chown -R bengel:bengel /home/bengel/stacks
chown -R bengel:www-data /var/www
find /var/www -type d -exec chmod 2775 {} \;
# 4) .env-Dateien wiederherstellen
# Die liegen NICHT in Git, sondern werden separat in einem Passwort-Manager
# (1Password / Bitwarden) gehalten — händisch in jeden Stack reinkopieren:
# ~/stacks/mgmt/.env, ~/stacks/git/.env, ~/stacks/apps/.env
# /var/www/admin/.env, /var/www/b2b/.env, /var/www/cms/.env
# 5) Hochfahren in der richtigen Reihenfolge
cd /home/bengel/stacks/proxy && docker compose up -d
cd /home/bengel/stacks/mgmt && docker compose up -d
cd /home/bengel/stacks/git && docker compose up -d
cd /home/bengel/stacks/apps && docker compose up -d
# 6) Caddy macht beim ersten Request automatisch neue Zertifikate
# (sofern DNS schon umgestellt ist und die alten caddy_data nicht restored wurden)
# 7) Smoke-Tests
curl -I https://admin.matev.eu
curl -I https://cms.matev.eu
curl -I https://staging.matev.euWas NICHT durchs Backup abgedeckt ist
.env-Dateien: bewusst nicht im Git, also auch nicht im Restic — separat im Passwort-Manager halten.- OAuth-Client-Secrets in Forgejo/Taiga/Penpot: stehen meistens nur in deren DB, sind aber im DB-Dump enthalten — außer sie wurden über UI nach dem Restore neu generiert.
- Build-Caches in
/var/lib/docker/: kommen beim nächsten Build neu, kein Backup nötig. - Let's-Encrypt-Account-Key: liegt in
caddy_data— wenn man Variante A oben nimmt, ist er drin.
Vergleich Git-based Compose vs. Portainer-managed Stacks
Das hier ist auch der Grund, warum wir Stacks nicht in Portainer anlegen, sondern als Git-Repo halten:
| Git-based Compose (✅ unsere Wahl) | Portainer-managed | |
|---|---|---|
| Stack-Definition wo? | ~/stacks/<name>/docker-compose.yml (in Git) | portainer.db |
| Disaster-Recovery | git clone + Restic-Restore + up -d | Portainer muss vor allem anderen wieder laufen, dann erst die Stacks |
| Versionierung | git log zeigt jede Änderung | nur Portainer-UI-Audit |
| Review vor Änderung | Pull-Request | wer im UI klickt, ändert prod |
| Backup-Aufwand | Volumes + Dumps | Volumes + Dumps + portainer.db |
| Anzahl SPOFs | Restic + Git | Restic + Git + Portainer-DB |
Portainer bleibt also nur Cockpit: Logs ansehen, Container neu starten, Shell aufmachen, Resource-Verbrauch sehen. Stacks erstellt/editiert man dort nicht.