# Server-brede backup (restic + NAS + B2, dashboard-bediend) ## Context `scrum4me-srv` draait een Docker-stack (Scrum4Me-web, worker-idea, ops-dashboard, postgres-17, caddy) plus Forgejo. De huidige backup-dekking — alleen `pg_dump ops_dashboard` naar `/srv/ops/backups/` met 30 dagen retentie op één disk — laat **alles anders** vallen: Scrum4Me-data, Forgejo, Caddy-certs, Docker-volumes en `/etc` zijn weg bij brand, diefstal, ransomware of disk-fail. Doel: de server **herbouwbaar** maken vanuit een encrypted, gededupliceerde, versioned backup met twee onafhankelijke kopieën — **NAS** lokaal en **Backblaze B2** offsite — bediend vanuit de ops-dashboard. De bestaande `backup_ops_db`-flow blijft draaien; restic pickt zijn dump-directory mee. **Belangrijke ontwerpkeuzes** (uitgebreid toegelicht in de review onder `/Users/janpetervisser/Development/Scrum4Me/docs/recommendations/server-backup-plan-review-2026-05-15.md`): - **B2 Object Lock + server-key zonder `deleteFiles`** — een aanvaller met root op de server kan geen B2-snapshots weghalen tot Object Lock-retention verloopt. Dat is de ransomware-bescherming. Prune op B2 gebeurt maandelijks vanaf de laptop met een aparte hoge-cap maintenance-key. - **Authoritative restore-bron = dumps, niet live datadirs.** Postgres- en Forgejo-data-directories zijn expliciet `--exclude`'d uit restic; `pg_dumpall` en `forgejo dump` + aparte `pg_dump ` zijn de autoritatieve bronnen. - **Phase-based script met structured statusfile.** Eén falende fase laat de rest doorlopen; per-phase status / exit-code / timestamps / error-tail komen in `/srv/backups/status/last-run.json` die de dashboard live leest. - **Single-instance lock** via `flock /run/server-backup.lock` — UI-knop en systemd-timer kunnen elkaar niet overlappen. ## Voorwaarden (aantoonbaar voldaan vóór uitvoering) - [ ] Bash, jq, restic, docker, gzip, flock op `$PATH` (`apt install restic jq` voor de eerste twee — de rest zit standaard). - [ ] De Scrum4Me-stack draait in Docker (`docker ps | grep scrum4me-postgres`). - [ ] `/srv/scrum4me/compose/docker-compose.yml` bestaat (anders herzie je het exclude-pad in `server-backup.sh`). - [ ] Tijd loopt synchroon (`timedatectl status`) — backups gebruiken ISO-timestamps. ## Voorwaarden (input van de gebruiker nodig) - **NAS-mount** — pad zoals `/mnt/backup-server` met genoeg ruimte (initieel ≥ 100 GB; restic is gededupliceerd, dus daarna groeit het traag). - **Backblaze B2-account** — credit-card geregistreerd, bucket aanmaken vereist een operator-actie. - **Restic-wachtwoord** — `openssl rand -hex 24`, bewaard in je password manager **én** in `/etc/restic-backup.password` op de server. Beide nodig — kwijt op één plek = repo onleesbaar. - **B2 maintenance-key** — bewaard alleen op je laptop in passwordmanager. Niet op de server. --- ## Deel A — Voorbereiding op `scrum4me-srv` Uit te voeren als `root` op `scrum4me-srv`. 1. **Tools installeren** ```bash sudo apt update sudo apt install -y restic jq restic version ``` 2. **Directories aanmaken** ```bash sudo mkdir -p /srv/backups/scripts /srv/backups/logs /srv/backups/status \ /var/backups/databases sudo chmod 0750 /srv/backups/logs /srv/backups/status ``` 3. **NAS-mount controleren / aanmaken** ```bash mountpoint -q /mnt/backup-server && echo "OK" || echo "NIET gemount" ``` Zo nee: `fstab`-regel toevoegen, `systemctl daemon-reload`, `mount -a`. Zorg dat de mount automatisch terugkomt bij reboot — anders crashed de eerste backup-run na een reboot. 4. **Restic-wachtwoord genereren en plaatsen** ```bash sudo sh -c 'openssl rand -hex 24 > /etc/restic-backup.password' sudo chmod 0400 /etc/restic-backup.password sudo chown root:root /etc/restic-backup.password ``` **Kopieer dezelfde string naar je password manager** vóór je verder gaat. Een gegeneerd wachtwoord dat alleen op de server staat is geen wachtwoord — het is een ticking time bomb. --- ## Deel B — Backblaze B2 inrichten (Object Lock + scoped keys) Doel: een bucket waarvan **bestaande** snapshots niet door de server gewist kunnen worden, plus twee separate keys: één voor de server (alleen schrijven/lezen) en één voor de operator (alle rechten, alleen vanaf laptop gebruikt). 1. **Bucket aanmaken** in de Backblaze-UI of via `b2` CLI: - Naam: `scrum4me-srv-backup` (of een variant; vermeld in `/etc/restic-backup.env`). - Privacy: **Private**. - **File Lock: Enabled, Governance mode, default retention = 30 days**. Governance betekent: een key met `bypassGovernance` kan locks omzeilen — die capability geven we **alleen** aan de maintenance-key. - Lifecycle rules: **geen** (lifecycle conflicts met Object Lock). - Encryption: server-side encryption aanlaten (B2 standaard). 2. **Server-key** aanmaken (gaat naar `/etc/restic-backup.env` op de server): ```bash # via b2 CLI: b2 application-key create \ --bucket scrum4me-srv-backup \ --name-prefix scrum4me-srv \ server-backup-key \ listBuckets,listFiles,readFiles,writeFiles ``` Bewaar de output (`keyID` + `applicationKey`). Verifieer in de UI dat de key **niet** `deleteFiles`, **niet** `deleteKeys`, **niet** `bypassGovernance` heeft. 3. **Maintenance-key** aanmaken (gaat in je password manager op de laptop): ```bash b2 application-key create \ --bucket scrum4me-srv-backup \ scrum4me-srv-maintenance-key \ listBuckets,listFiles,readFiles,writeFiles,deleteFiles,bypassGovernance ``` Deze key komt **nooit** op de server. Gebruik alleen voor `restic forget --prune` vanaf je laptop (zie Deel H). 4. **`/etc/restic-backup.env` aanmaken** ```bash sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/restic-backup.env.example \ /etc/restic-backup.env sudo chmod 0600 /etc/restic-backup.env sudo chown root:root /etc/restic-backup.env sudo nano /etc/restic-backup.env ``` Vul in: `RESTIC_REPO_NAS`, `RESTIC_REPO_B2`, `B2_ACCOUNT_ID` (= keyID), `B2_ACCOUNT_KEY` (= applicationKey). Forgejo-velden in Deel F. **Dreigingsmodel** | Dreiging | Gedekt door dit ontwerp? | |---|---| | Disk-fail / corruptie | ✓ NAS + B2 = 2× redundancy | | Brand / diefstal / waterschade | ✓ B2 is offsite | | Ransomware op de server | ✓ B2 Object Lock — bestaande snapshots immutable tot retention verloopt | | Server-compromise (root) | ✓ server-key kan geen B2-files verwijderen | | Laptop-compromise + server-compromise simultaan | ✗ maintenance-key dan ook in handen van aanvaller — geen verdediging | | Backblaze account-compromise | ✗ — buiten scope; mitigeer met 2FA en audit-trail | | Verlies restic-wachtwoord | ✗ — repos onleesbaar; bewaar wachtwoord óók in password manager | --- ## Deel C — Restic-repos initialiseren 1. **NAS-repo init** ```bash sudo -E bash -c ' set -a; . /etc/restic-backup.env; set +a export RESTIC_PASSWORD_FILE=/etc/restic-backup.password restic -r "$RESTIC_REPO_NAS" init ' ``` 2. **B2-repo init** ```bash sudo -E bash -c ' set -a; . /etc/restic-backup.env; set +a export RESTIC_PASSWORD_FILE=/etc/restic-backup.password restic -r "$RESTIC_REPO_B2" init ' ``` 3. **Retentie droogtest** — controleer dat het forget-beleid niet té agressief is op een eerste-snapshot-only repo. (Op een verse repo verwijdert `forget` niets, maar dit toont dat alle paden + auth werken.) ```bash sudo -E bash -c ' set -a; . /etc/restic-backup.env; set +a export RESTIC_PASSWORD_FILE=/etc/restic-backup.password restic -r "$RESTIC_REPO_NAS" forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --dry-run ' ``` --- ## Deel D — Scripts en systemd-units plaatsen 1. **Scripts kopiëren** ```bash sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/server-backup.sh /srv/backups/scripts/ sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/restore-test.sh /srv/backups/scripts/ sudo chmod 0750 /srv/backups/scripts/*.sh sudo chown root:root /srv/backups/scripts/*.sh ``` 2. **Systemd-units kopiëren** ```bash sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/server-backup.service /etc/systemd/system/ sudo cp /srv/ops/repos/ops-dashboard/deploy/server-backup/server-backup.timer /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now server-backup.timer ``` 3. **Timer verifiëren** ```bash systemctl list-timers | grep server-backup ``` Toont next-run morgen 03:30 (+ randomized delay tot 10 min). --- ## Deel E — Eerste run handmatig + statusfile-verificatie 1. **Trigger** ```bash sudo systemctl start server-backup.service ``` 2. **Live volgen** ```bash journalctl -u server-backup.service -f ``` Verwacht: 8 fasen (postgres_dump, forgejo_dump, forgejo_db_dump, restic_nas, restic_b2, forget_nas, check_nas, check_b2), elk met een `─── phase: X ───` start- en `─── end X (exit=N, status=S)` eindregel. 3. **Statusfile** ```bash sudo jq . /srv/backups/status/last-run.json ``` Verwacht: `overall_status: "success"`, alle 5 verplichte fasen `success` (Forgejo mag `skipped` zijn als die nog niet geconfigureerd is). 4. **Snapshots** ```bash sudo -E bash -c ' set -a; . /etc/restic-backup.env; set +a export RESTIC_PASSWORD_FILE=/etc/restic-backup.password restic -r "$RESTIC_REPO_NAS" snapshots restic -r "$RESTIC_REPO_B2" snapshots ' ``` Beide tonen één snapshot met `host=scrum4me-srv` en tags `scheduled`. --- ## Deel F — Forgejo subplan Vóór de eerste full-backup run: inventariseer Forgejo en bevestig (of corrigeer) de defaults in `restic-backup.env`. Bij twijfel — zet `FORGEJO_CONTAINER=` (leeg) zodat de Forgejo-fases als `skipped` markeren tot je verifieerd hebt. ### F1. Inventarisatie ```bash docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' | grep -i forgejo ``` Noteer: - container-naam (vermoedelijk `forgejo`). - image-versie (`codeberg.org/forgejo/forgejo:`). ### F2. Configpaden in de container ```bash docker inspect --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}' docker exec ls -la /data/gitea/conf/app.ini ``` Standaard: `app.ini` in `/data/gitea/conf/app.ini` binnen de container. Wijkt dat af, pas `FORGEJO_CONFIG=` in `/etc/restic-backup.env` aan. ### F3. DB-koppeling controleren ```bash docker exec grep -E '^DB_TYPE|^HOST|^NAME|^USER' /data/gitea/conf/app.ini ``` - `DB_TYPE=postgres` met `NAME=forgejo` ⇒ zet `FORGEJO_DB_NAME=forgejo`, en als de Postgres-container niet `scrum4me-postgres` is: `FORGEJO_DB_CONTAINER=...`. - `DB_TYPE=sqlite` ⇒ laat `FORGEJO_DB_NAME=` leeg; SQLite-DB komt mee in `forgejo dump`. ### F4. Dump-strategie Het script doet **drie** dingen voor Forgejo: 1. `forgejo dump --skip-db -c --type zip -f -` — codebases, attachments, hooks, LFS metadata, etc. 2. Separate `pg_dump ` — autoritatieve DB-restore-bron (Forgejo docs documenteren bekende import-issues bij DB-inhoud uit `forgejo dump`, daarom `--skip-db`). 3. Live datadirs (`/srv/forgejo/data/git`, `/srv/forgejo/data/lfs`, `/srv/forgejo/data/queues`) worden **niet** door restic gekopieerd — dat zijn live B-Trees waar een file-level kopie inconsistent zou zijn. ### F5. Restore-test in geïsoleerde compose-stack Vóór je de Forgejo-restore voor real nodig hebt: test hem een keer. Maak een tijdelijke directory met een verse Forgejo + Postgres, voer de dumps in, draai `forgejo doctor check --all`. ```bash # Minimaal restore-test-recept (vul in op basis van je Forgejo-versie) RESTORE_DIR=/tmp/forgejo-restore-test mkdir -p "$RESTORE_DIR" cd "$RESTORE_DIR" # 1. compose-stack met blanco Forgejo + Postgres cat > docker-compose.yml <<'YAML' services: forgejo: image: codeberg.org/forgejo/forgejo: volumes: [ "./forgejo-data:/data" ] depends_on: [ db ] db: image: postgres:17 environment: POSTGRES_USER: forgejo POSTGRES_PASSWORD: testtest POSTGRES_DB: forgejo volumes: [ "./db-data:/var/lib/postgresql/data" ] YAML docker compose up -d # 2. DB-dump terugzetten gunzip < /var/backups/databases/forgejo-db-$(date +%F).sql.gz \ | docker compose exec -T db psql -U forgejo forgejo # 3. Forgejo-dump uitpakken in de data-volume docker compose stop forgejo unzip /var/backups/databases/forgejo-$(date +%F).zip -d forgejo-data/ docker compose start forgejo # 4. Health-checks docker compose exec forgejo forgejo doctor check --all curl -fsS http://localhost:3000/api/v1/version ``` Slaagt `forgejo doctor check --all` en het `/api/v1/version`-endpoint? Dan is je Forgejo-restore werkend. Tear-down: `docker compose down -v && rm -rf "$RESTORE_DIR"`. --- ## Deel G — Restore-procedure in productie ### G1. Files uit een snapshot terughalen ```bash # Snapshot kiezen sudo -E bash -c ' set -a; . /etc/restic-backup.env; set +a export RESTIC_PASSWORD_FILE=/etc/restic-backup.password restic -r "$RESTIC_REPO_NAS" snapshots ' # Restore (latest, alleen /etc — voorbeeld) sudo -E bash -c ' set -a; . /etc/restic-backup.env; set +a export RESTIC_PASSWORD_FILE=/etc/restic-backup.password restic -r "$RESTIC_REPO_NAS" restore latest --target /tmp/restore --include /etc ' ``` ### G2. Postgres herstellen (Scrum4Me-cluster) ```bash # Stop de apps die met de DB praten docker compose -f /srv/scrum4me/compose/docker-compose.yml stop scrum4me-web ops-dashboard worker-idea # Restore dumpall (drop + recreate alle DBs in de cluster — vandaar --clean --if-exists in de dump) gunzip < /var/backups/databases/postgres-2026-05-15.sql.gz \ | docker exec -i scrum4me-postgres psql -U scrum4me # Apps weer aan docker compose -f /srv/scrum4me/compose/docker-compose.yml start scrum4me-web ops-dashboard worker-idea ``` Voor partial restore (alleen één database): pak die DB uit de dumpall-tekst met `pg_restore` of `awk`-block extractie. Voor alleen `ops_dashboard` is de bestaande [recovery.md](recovery.md) sectie 2a primair. ### G3. Forgejo herstellen Volg [F5](#f5-restore-test-in-geïsoleerde-compose-stack) maar dan met de echte Forgejo-compose-stack en zonder tear-down. Belangrijk: stop de live Forgejo eerst, vervang `/srv/forgejo/data` volledig, restore DB, start Forgejo, `forgejo doctor check --all`. --- ## Deel H — Maintenance vanaf de laptop (maandelijks) Doel: B2-snapshots ouder dan retention-policy daadwerkelijk pruning, plus een diepere integriteits-check die op de server te duur zou zijn. 1. **Voorbereiding** (eenmalig op laptop): ```bash brew install restic jq # Maintenance-key uit password manager export B2_ACCOUNT_ID= export B2_ACCOUNT_KEY= export RESTIC_REPOSITORY=b2:scrum4me-srv-backup:scrum4me-srv read -rs RESTIC_PASSWORD < /dev/tty # uit password manager export RESTIC_PASSWORD ``` 2. **Prune-check** (eerst dry-run om te zien wat er zou gebeuren): ```bash restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --dry-run ``` 3. **Daadwerkelijke prune** (vereist `bypassGovernance` capability — alleen via maintenance-key): ```bash restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune ``` 4. **Diepere check**: ```bash restic check --read-data-subset=10% ``` B2-bandbreedte: 10% van een 50 GB repo = 5 GB download, B2-prijs ~ $0.05 (gratis 1 GB/dag). 5. **Cleanup environment** — sluit shell of `unset RESTIC_PASSWORD B2_ACCOUNT_*`. --- ## Deel I — Integriteits-schedule (samenvatting) | Cadans | Wie | Wat | Waarom | |---|---|---|---| | Dagelijks 03:30 | server (systemd timer) | `restic check` op beide repos | snelle metadata-/structure-validatie | | Wekelijks (zondag) | server (zelfde script) | `restic check --read-data-subset=2.5%` op NAS, `1%` op B2 | sample-based data-integrity | | Maandelijks | operator (laptop) | `restic check --read-data-subset=10%` + `forget --prune` op B2 | diepere check + prune (B2 server-key heeft geen delete-rechten) | | Maandelijks | operator (server) | `/srv/backups/scripts/restore-test.sh nas` + handmatige Forgejo-stack-restore (F5) | end-to-end restore-verificatie | --- ## Te wijzigen / nieuw aangemaakte bestanden **Op `scrum4me-srv`** (alleen via deploy uit deze repo, geen handmatige edits): - `/srv/backups/scripts/server-backup.sh` (uit `deploy/server-backup/`). - `/srv/backups/scripts/restore-test.sh` (idem). - `/etc/systemd/system/server-backup.service`, `server-backup.timer` (uit `deploy/server-backup/`). - `/etc/restic-backup.env` — secrets, niet in repo. - `/etc/restic-backup.password` — secret, niet in repo. **In deze repo (`ops-dashboard`)**, nieuw aangemaakt: - `deploy/server-backup/*` — alle deploy-artefacten. - `docs/runbooks/server-backup.md` — dit document. - Later (Fase 3+4): `ops-agent/commands.yml.example`-uitbreiding, `ops-agent/flows.example/server_backup_*.yml`, `app/settings/backups/_components/server-backup-section.tsx`. **Op de laptop**, in password manager: - restic-wachtwoord (identiek aan `/etc/restic-backup.password`). - B2 maintenance-key (keyID + applicationKey). --- ## Veelvoorkomende fouten | Symptoom | Oorzaak | Fix | |---|---|---| | `unable to open repository ... no such file or directory` (NAS) | NAS-mount weg na reboot | `mountpoint -q /mnt/backup-server` — fix `fstab`/`autofs`; herstart `server-backup.service` | | `unable to open repository ... AccessDenied` (B2) | server-key heeft verkeerde capabilities of bucket-prefix | check `b2 application-key list`; capabilities moeten `listBuckets,listFiles,readFiles,writeFiles` zijn, name-prefix moet matchen | | `Object Lock In Place` bij `forget --prune` op B2 | server probeert ten onrechte B2 te prunen (heeft die capability niet) | het script prune'd alleen NAS — als deze fout opduikt: handmatige `restic forget` op B2 gedraaid (zou off-server moeten); gebruik maintenance-key | | `restic snapshot tag scheduled` ontbreekt in UI | run heeft `--tag scheduled` niet meegekregen | check script — `restic_backup_to` zet beide tags hardcoded | | `forgejo dump` faalt met permission denied | container-user niet `git` | pas `dump_forgejo` aan: `docker exec -u ` | | restic exit code 3 in statusfile | sommige files waren niet leesbaar tijdens snapshot (open file lock) | non-fataal — log toont welke files; meestal logs of sockets; eventueel toevoegen aan `RESTIC_EXCLUDES` | | `another server-backup is already running` exit 75 | timer en UI-knop tegelijk, of vorige run hangt | `systemctl status server-backup.service`; bij hang: `systemctl kill server-backup.service`, lockfile `/run/server-backup.lock` opruimen | | `last-run.json` niet geüpdatet | script gecrashed vóór `write_status_json` | `journalctl -u server-backup.service --since=today` — meestal env-file of password-file probleem | | Postgres-datadir in restic snapshot terug te zien | excludes verkeerd geconfigureerd | check `RESTIC_EXCLUDES` in script — moet `/srv/scrum4me/postgres` bevatten | --- ## Verificatie (end-to-end) 1. **Eerste run slaagt** — Deel E groen, statusfile `overall_status: success`. 2. **Snapshots zichtbaar** op beide repos via `restic snapshots`. 3. **Restore-test slaagt** — `restore-test.sh nas` → `overall_status: success` in `/srv/backups/status/last-restore-test.json`, alle assertions `ok`. 4. **Forgejo-restore-stack** (F5) — `forgejo doctor check --all` rond zonder errors, `/api/v1/version` antwoordt. 5. **Reboot-test** — server reboot, `systemctl list-timers` toont `server-backup.timer` met next-run gepland; NAS-mount automatisch terug. 6. **Failure-injectie**: - NAS unmount → script eindigt met `overall_status: partial_failure`, `phases.restic_nas.status: failed`, B2-snapshot wel aanwezig, systemd exit 75. - B2-key tijdelijk ongeldig → `phases.restic_b2.status: failed`, NAS-snapshot wel, exit 75. - Beide repos onbereikbaar → `overall_status: failed`, exit 1. 7. **Concurrency** — tweede `systemctl start server-backup.service` tijdens lopende run → exit 75, log toont `another server-backup is already running`. 8. **Maandelijkse maintenance** — eerst keer succesvol uitgevoerd vanaf laptop, B2 `forget --prune` slaagt zonder Object Lock-fouten. --- # Addendum — uitvoering > Vul deze sectie na de eerste uitvoering met alle afwijkingen van het plan > hierboven: exacte Forgejo container-naam, image-versie, eventuele paden die > anders bleken, sudoers-precieze regels, Object Lock-retention die je gekozen > hebt, B2 key-IDs (geredacteerd), tijden van eerste runs, etc. Zelfde > discipline als [tailscale-setup.md](tailscale-setup.md).