Skip to content

Commit b1e206c

Browse files
author
mandariSoftware
committed
feat(betrieb): tägliche verschlüsselte Offsite-Backups mit restic (#235)
Neuer Backup-Stack unter deploy/backup/, unabhängig vom mandari-Stack: - Ein Container (postgres:16-alpine + restic 0.19.1) übernimmt per Cron - täglich: Dumps aller Datenbanken in der Hauptversion der Produktion samt Rollen, Dateien, Uploads, Konfiguration inkl. .env und TLS-Daten in bis zu zwei Repositorys an getrennten Standorten sichern - Aufbewahrung: je Tag der letzte Stand, höchstens 30 Tage (forget --keep-within-daily 30d --prune) - wöchentlich: restic check, rotierend ein Viertel der Daten - monatlich: Wiederherstellungstest in einer Wegwerf-Datenbank im Container (Zeilenzahlen gegen Produktion, Datei-Prüfsummen) - Verschlüsselung vor der Übertragung, Ziele per SFTP (kein Programm auf dem Zielsystem nötig), Host-Keys fest hinterlegt, Quellen nur lesend, kein Docker-Socket im Container - scripts/backup-watchdog.sh auf dem Host: Mail über den mandari-Container bei Fehler, veralteter Sicherung, fehlgeschlagener Prüfung oder fehlgeschlagenem Wiederherstellungstest - docs/BACKUP.md mit Einrichtung, Bedienung und Wiederherstellung bis zum kompletten Neuaufbau; TOM-Dokumentation präzisiert Teil von #229.
1 parent 1f40fd1 commit b1e206c

11 files changed

Lines changed: 778 additions & 1 deletion

File tree

‎deploy/backup/.env.example‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# =============================================================================
2+
# mandari Backup-Stack – Beispielkonfiguration (cp .env.example .env)
3+
# =============================================================================
4+
5+
# restic-Repositorys an zwei getrennten Standorten (SFTP genügt, auf dem
6+
# Zielsystem wird kein Programm ausgeführt). Das zweite ist optional,
7+
# für den Produktivbetrieb aber dringend empfohlen.
8+
RESTIC_REPO_1=sftp:benutzer@backup-host-1.example:/mandari
9+
RESTIC_REPO_1_LABEL=standort-1
10+
RESTIC_REPO_2=sftp:benutzer@backup-host-2.example:/mandari
11+
RESTIC_REPO_2_LABEL=standort-2
12+
13+
# Zeitpläne (cron-Syntax, Zeitzone BACKUP_TZ)
14+
BACKUP_CRON="30 2 * * *"
15+
BACKUP_CHECK_CRON="30 6 * * 0"
16+
BACKUP_RESTORE_TEST_CRON="30 4 3 * *"
17+
BACKUP_TZ=Europe/Berlin
18+
# Aufbewahrung: je Tag der letzte Stand, höchstens 30 Tage
19+
BACKUP_KEEP_WITHIN_DAILY=30d
20+
# Name der Sicherungsstände und des Stacks
21+
BACKUP_HOSTNAME=mandari
22+
BACKUP_COMPOSE_PROJECT=mandari-backup
23+
24+
# Datenbank: Superuser des PostgreSQL-Containers im Docker-Netz des mandari-Stacks.
25+
# BACKUP_POSTGRES_IMAGE muss dieselbe Hauptversion wie die Datenbank haben.
26+
BACKUP_DB_NETWORK=mandari_mandari
27+
BACKUP_PG_HOST=mandari-postgres
28+
BACKUP_PG_USER=mandari
29+
BACKUP_PG_PASSWORD=
30+
BACKUP_POSTGRES_IMAGE=postgres:16-alpine
31+
BACKUP_RESTORE_TEST_DB=mandari
32+
33+
# Quellen (Host-Pfade, nur lesend eingehängt)
34+
BACKUP_SRC_CONFIG=../..
35+
BACKUP_SRC_MEDIA=/var/lib/docker/volumes/mandari_media_data/_data
36+
BACKUP_SRC_FILES=/var/lib/docker/volumes/mandari_files_data/_data
37+
BACKUP_SRC_CADDY=/var/lib/docker/volumes/mandari_caddy_data/_data
38+
39+
# Schlüssel, Passwort, Status und Arbeitsverzeichnis (nur für root lesbar anlegen).
40+
# Das restic-Passwort zusätzlich außerhalb des Servers verwahren – ohne es sind
41+
# die Sicherungen nicht lesbar.
42+
BACKUP_SSH_DIR=./ssh
43+
BACKUP_PASSWORD_FILE=./secrets/restic_password
44+
BACKUP_STATUS_DIR=./status
45+
BACKUP_WORK_DIR=./work
46+
47+
# Überwachung (scripts/backup-watchdog.sh auf dem Host)
48+
# Mailversand über den mandari-Container (SMTP aus den Systemeinstellungen)
49+
BACKUP_ALERT_TO=
50+
BACKUP_MAIL_CONTAINER=mandari
51+
BACKUP_MAX_AGE_HOURS=26

‎deploy/backup/.gitattributes‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*.sh text eol=lf
2+
Dockerfile text eol=lf
3+
*.yml text eol=lf
4+
.env.example text eol=lf

‎deploy/backup/.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.env
2+
docker-compose.override.yml
3+
ssh/
4+
secrets/
5+
status/
6+
work/

‎deploy/backup/Dockerfile‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# =============================================================================
3+
# mandari-backup: restic + PostgreSQL-Client in der Hauptversion der Produktion
4+
# =============================================================================
5+
# Die Dumps entstehen mit derselben PostgreSQL-Hauptversion wie die Datenbank
6+
# und lassen sich im Notfall mit deren eigenem pg_restore einspielen.
7+
# Das Image dient zugleich als Wegwerf-Datenbank für den Wiederherstellungstest.
8+
# =============================================================================
9+
ARG POSTGRES_IMAGE=postgres:16-alpine
10+
11+
FROM restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510 AS restic
12+
13+
FROM ${POSTGRES_IMAGE}
14+
15+
# Bewusst ohne Versions-Pin: der SSH-Client soll Sicherheitsupdates beim Neubau erhalten
16+
# hadolint ignore=DL3018
17+
RUN apk add --no-cache openssh-client-default
18+
19+
COPY --from=restic /usr/bin/restic /usr/local/bin/restic
20+
COPY scripts/backup-entrypoint.sh /usr/local/bin/backup-entrypoint
21+
COPY scripts/mandari-backup.sh /usr/local/bin/mandari-backup
22+
RUN chmod 755 /usr/local/bin/backup-entrypoint /usr/local/bin/mandari-backup \
23+
&& restic version && pg_dump --version && ssh -V
24+
25+
ENTRYPOINT ["/usr/local/bin/backup-entrypoint"]
26+
CMD []

‎deploy/backup/docker-compose.yml‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# =============================================================================
3+
# mandari – verschlüsselte Offsite-Backups mit restic
4+
# =============================================================================
5+
# Ein Container übernimmt per Cron:
6+
# täglich Datenbank-Dumps + Dateien + Konfiguration in zwei Repositorys
7+
# an getrennten Standorten sichern, Stände > 30 Tage löschen
8+
# wöchentlich Integritätsprüfung (rotierend ein Viertel der Daten)
9+
# monatlich Wiederherstellungstest in einer Wegwerf-Datenbank
10+
# Die Daten werden vor der Übertragung verschlüsselt (AES-256, restic).
11+
#
12+
# cp .env.example .env # Werte eintragen
13+
# docker compose build
14+
# docker compose run --rm backup mandari-backup init
15+
# docker compose up -d
16+
#
17+
# Anleitung inkl. Wiederherstellung: docs/BACKUP.md
18+
# =============================================================================
19+
20+
name: ${BACKUP_COMPOSE_PROJECT:-mandari-backup}
21+
22+
x-logging: &default-logging
23+
driver: json-file
24+
options:
25+
max-size: "10m"
26+
max-file: "5"
27+
28+
services:
29+
backup:
30+
build:
31+
context: .
32+
args:
33+
POSTGRES_IMAGE: ${BACKUP_POSTGRES_IMAGE:-postgres:16-alpine}
34+
image: ${BACKUP_COMPOSE_PROJECT:-mandari-backup}:local
35+
container_name: ${BACKUP_COMPOSE_PROJECT:-mandari-backup}
36+
restart: unless-stopped
37+
environment:
38+
TZ: ${BACKUP_TZ:-Europe/Berlin}
39+
BACKUP_CRON: "${BACKUP_CRON:-30 2 * * *}"
40+
BACKUP_CHECK_CRON: "${BACKUP_CHECK_CRON:-30 6 * * 0}"
41+
BACKUP_RESTORE_TEST_CRON: "${BACKUP_RESTORE_TEST_CRON:-30 4 3 * *}"
42+
BACKUP_HOSTNAME: ${BACKUP_HOSTNAME:-mandari}
43+
BACKUP_KEEP_WITHIN_DAILY: ${BACKUP_KEEP_WITHIN_DAILY:-30d}
44+
BACKUP_RESTORE_TEST_DB: ${BACKUP_RESTORE_TEST_DB:-mandari}
45+
RESTIC_REPO_1: ${RESTIC_REPO_1:?RESTIC_REPO_1 fehlt (siehe .env.example)}
46+
RESTIC_REPO_1_LABEL: ${RESTIC_REPO_1_LABEL:-standort-1}
47+
RESTIC_REPO_2: ${RESTIC_REPO_2:-}
48+
RESTIC_REPO_2_LABEL: ${RESTIC_REPO_2_LABEL:-standort-2}
49+
RESTIC_PASSWORD_FILE: /run/secrets/restic_password
50+
BACKUP_PG_HOST: ${BACKUP_PG_HOST:-mandari-postgres}
51+
BACKUP_PG_USER: ${BACKUP_PG_USER:-mandari}
52+
PGPASSWORD: ${BACKUP_PG_PASSWORD:?BACKUP_PG_PASSWORD fehlt (siehe .env.example)}
53+
volumes:
54+
- ${BACKUP_SSH_DIR:-./ssh}:/root/.ssh:ro
55+
- ${BACKUP_PASSWORD_FILE:-./secrets/restic_password}:/run/secrets/restic_password:ro
56+
- ${BACKUP_STATUS_DIR:-./status}:/status
57+
- ${BACKUP_WORK_DIR:-./work}:/work
58+
- restic_cache:/root/.cache/restic
59+
# Quellen (nur lesend) – alles unter /source landet im Backup.
60+
# Weitere Verzeichnisse per docker-compose.override.yml unter /source/… einhängen.
61+
- ${BACKUP_SRC_CONFIG:-../..}:/source/config:ro
62+
- ${BACKUP_SRC_MEDIA:-/var/lib/docker/volumes/mandari_media_data/_data}:/source/media:ro
63+
- ${BACKUP_SRC_FILES:-/var/lib/docker/volumes/mandari_files_data/_data}:/source/files:ro
64+
- ${BACKUP_SRC_CADDY:-/var/lib/docker/volumes/mandari_caddy_data/_data}:/source/caddy:ro
65+
networks:
66+
- database
67+
mem_limit: 2g
68+
logging: *default-logging
69+
70+
volumes:
71+
restic_cache:
72+
73+
networks:
74+
database:
75+
external: true
76+
name: ${BACKUP_DB_NETWORK:-mandari_mandari}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
# Entrypoint des Backup-Containers: Umgebung für Cron ablegen, Zeitpläne setzen,
4+
# crond im Vordergrund starten. Mit Argumenten wird stattdessen der Befehl ausgeführt.
5+
set -euo pipefail
6+
7+
# busybox-crond gibt die Container-Umgebung nicht an Jobs weiter
8+
(
9+
umask 077
10+
export -p | grep -E '^declare -x (RESTIC_|BACKUP_|PG|TZ=)' > /run/mandari-backup.env
11+
)
12+
13+
if [ $# -gt 0 ]; then
14+
exec "$@"
15+
fi
16+
17+
cat > /etc/crontabs/root <<EOF
18+
${BACKUP_CRON:-30 2 * * *} /usr/local/bin/mandari-backup run > /proc/1/fd/1 2>&1
19+
${BACKUP_CHECK_CRON:-30 6 * * 0} /usr/local/bin/mandari-backup check > /proc/1/fd/1 2>&1
20+
${BACKUP_RESTORE_TEST_CRON:-30 4 3 * *} /usr/local/bin/mandari-backup restore-test > /proc/1/fd/1 2>&1
21+
EOF
22+
23+
echo "mandari-backup: $(restic version | cut -d' ' -f1-2), $(pg_dump --version), Zeitzone ${TZ:-UTC}"
24+
echo "Zeitpläne:"
25+
sed 's/^/ /' /etc/crontabs/root
26+
27+
exec crond -f -l 6
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
# =============================================================================
4+
# Überwachung der Backups (stündlich per Cron auf dem Host, als root)
5+
# =============================================================================
6+
# Meldet per Mail über den mandari-Container (SMTP aus den Systemeinstellungen):
7+
# - Backup-Container läuft nicht
8+
# - letzte Sicherung endete mit Fehler
9+
# - letzte erfolgreiche Sicherung älter als BACKUP_MAX_AGE_HOURS
10+
# - Integritätsprüfung oder Wiederherstellungstest fehlgeschlagen
11+
# Solange ein Problem besteht, höchstens eine Mail je 6 Stunden.
12+
#
13+
# scripts/backup-watchdog.sh # prüfen (Exit 1 bei Problemen)
14+
# scripts/backup-watchdog.sh --test # Testmail senden
15+
# =============================================================================
16+
set -uo pipefail
17+
18+
BACKUP_DIR="${BACKUP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
19+
20+
env_value() {
21+
grep -E "^$1=" "$BACKUP_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | sed -E 's/^"(.*)"$/\1/'
22+
}
23+
24+
STATUS_DIR="$(env_value BACKUP_STATUS_DIR)"
25+
STATUS_DIR="${STATUS_DIR:-./status}"
26+
case "$STATUS_DIR" in
27+
/*) ;;
28+
*) STATUS_DIR="$BACKUP_DIR/${STATUS_DIR#./}" ;;
29+
esac
30+
MAX_AGE_HOURS="$(env_value BACKUP_MAX_AGE_HOURS)"; MAX_AGE_HOURS="${MAX_AGE_HOURS:-26}"
31+
ALERT_TO="$(env_value BACKUP_ALERT_TO)"
32+
MAIL_CONTAINER="$(env_value BACKUP_MAIL_CONTAINER)"; MAIL_CONTAINER="${MAIL_CONTAINER:-mandari}"
33+
BACKUP_CONTAINER="$(env_value BACKUP_COMPOSE_PROJECT)"; BACKUP_CONTAINER="${BACKUP_CONTAINER:-mandari-backup}"
34+
HOST_LABEL="$(env_value BACKUP_HOSTNAME)"; HOST_LABEL="${HOST_LABEL:-$(hostname)}"
35+
ALERT_STAMP="$STATUS_DIR/.alert-sent"
36+
37+
mkdir -p "$STATUS_DIR"
38+
39+
send_mail() {
40+
local subject="$1" body="$2"
41+
if [ -z "$ALERT_TO" ]; then
42+
echo "BACKUP_ALERT_TO ist nicht gesetzt – keine Mail versendet" >&2
43+
return 1
44+
fi
45+
docker exec -e ALERT_SUBJECT="$subject" -e ALERT_BODY="$body" -e ALERT_TO="$ALERT_TO" "$MAIL_CONTAINER" \
46+
python manage.py shell -c "import os
47+
from django.core.mail import send_mail
48+
send_mail(os.environ['ALERT_SUBJECT'], os.environ['ALERT_BODY'], None,
49+
[a.strip() for a in os.environ['ALERT_TO'].split(',') if a.strip()])" >/dev/null 2>&1
50+
}
51+
52+
if [ "${1:-}" = "--test" ]; then
53+
if send_mail "[$HOST_LABEL] Backup-Überwachung: Testmail" \
54+
"Die Backup-Überwachung auf $HOST_LABEL kann Mails versenden. Es ist keine Aktion nötig."; then
55+
echo "Testmail an $ALERT_TO versendet"
56+
exit 0
57+
fi
58+
echo "Testmail fehlgeschlagen" >&2
59+
exit 1
60+
fi
61+
62+
now=$(date +%s)
63+
problems=()
64+
65+
running=$(docker inspect -f '{{.State.Running}}' "$BACKUP_CONTAINER" 2>/dev/null || echo "nicht vorhanden")
66+
[ "$running" = "true" ] || problems+=("Der Backup-Container $BACKUP_CONTAINER läuft nicht (Status: $running).")
67+
68+
if [ -f "$STATUS_DIR/last_success" ]; then
69+
age=$(( (now - $(stat -c %Y "$STATUS_DIR/last_success")) / 3600 ))
70+
if [ "$age" -ge "$MAX_AGE_HOURS" ]; then
71+
problems+=("Die letzte erfolgreiche Sicherung ist $age Stunden alt ($(cat "$STATUS_DIR/last_success")).")
72+
fi
73+
else
74+
[ -f "$STATUS_DIR/.installed" ] || touch "$STATUS_DIR/.installed"
75+
age=$(( (now - $(stat -c %Y "$STATUS_DIR/.installed")) / 3600 ))
76+
if [ "$age" -ge "$MAX_AGE_HOURS" ]; then
77+
problems+=("Seit der Einrichtung vor $age Stunden gab es keine erfolgreiche Sicherung.")
78+
fi
79+
fi
80+
81+
if [ -s "$STATUS_DIR/last_error" ] &&
82+
{ [ ! -f "$STATUS_DIR/last_success" ] || [ "$STATUS_DIR/last_error" -nt "$STATUS_DIR/last_success" ]; }; then
83+
problems+=("Die letzte Sicherung endete mit einem Fehler: $(head -c 1500 "$STATUS_DIR/last_error")")
84+
fi
85+
86+
for marker in check_failed restore_test_failed; do
87+
if [ -s "$STATUS_DIR/$marker" ]; then
88+
case "$marker" in
89+
check_failed) what="Die letzte Integritätsprüfung ist fehlgeschlagen" ;;
90+
*) what="Der letzte Wiederherstellungstest ist fehlgeschlagen" ;;
91+
esac
92+
problems+=("$what: $(head -c 1500 "$STATUS_DIR/$marker")")
93+
fi
94+
done
95+
96+
if [ ${#problems[@]} -eq 0 ]; then
97+
rm -f "$ALERT_STAMP"
98+
exit 0
99+
fi
100+
101+
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${problems[@]}"
102+
103+
if [ -f "$ALERT_STAMP" ] && [ $(( now - $(stat -c %Y "$ALERT_STAMP") )) -lt 21600 ]; then
104+
exit 1
105+
fi
106+
107+
body="$(printf '%s\n\n' "${problems[@]}")
108+
Prüfen:
109+
docker logs --tail 200 $BACKUP_CONTAINER
110+
docker exec $BACKUP_CONTAINER mandari-backup snapshots
111+
ls -la $STATUS_DIR
112+
113+
Anleitung: docs/BACKUP.md im mandari-Repository."
114+
115+
if send_mail "[$HOST_LABEL] Backup-Warnung" "$body"; then
116+
touch "$ALERT_STAMP"
117+
else
118+
echo "Mailversand fehlgeschlagen" >&2
119+
fi
120+
exit 1

0 commit comments

Comments
 (0)