Ops-dashboard/docs/runbooks/server-backup.md
Madhura68 ab87c0fada feat(server-backup): restic dual-repo backup (NAS + B2) with dashboard UI
Adds a server-wide backup capability beyond the existing ops_dashboard
pg_dump flow:

- Daily systemd timer (03:30) runs pg_dumpall + Forgejo dump, then restic
  to a local NAS repo and an offsite Backblaze B2 repo with Object Lock.
  Phase-based script with single-instance flock, structured statusfile,
  systemd hardening, and live-datadir excludes (Postgres / Forgejo) so
  the dumps stay authoritative.
- Ops-agent gets nine new read-only/trigger commands (snapshots, stats,
  status, logs, plus two triggers) backed by sudoers-whitelisted wrapper
  scripts that source /etc/restic-backup.env so the agent never sees the
  restic password or B2 keys.
- Two new flows (server_backup_full, server_backup_restore_test) drive
  the dashboard's "Backup now" and "Restore test" buttons.
- /settings/backups gains a Server backup section with overall + per-phase
  status, NAS / B2 snapshot tables, restore-size / raw-data / dedup-ratio
  stats, and the last restore-test result. The existing pg_dump section
  is preserved unchanged.
- Runbook docs/runbooks/server-backup.md follows the tailscale-setup
  pattern (plan + addendum) and covers B2 Object Lock + scoped keys,
  Forgejo subplan with isolated restore-test stack, the off-server
  maintenance flow for B2 prune, and the integrity-check schedule.

Code-only change — installation on scrum4me-srv follows the runbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:03:00 +02:00

20 KiB
Raw Blame History

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 <forgejo_db> 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-wachtwoordopenssl 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

    sudo apt update
    sudo apt install -y restic jq
    restic version
    
  2. Directories aanmaken

    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

    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

    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):

    # 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):

    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

    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

    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

    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.)

    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

    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

    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

    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

    sudo systemctl start server-backup.service
    
  2. Live volgen

    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

    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

    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

docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' | grep -i forgejo

Noteer:

  • container-naam (vermoedelijk forgejo).
  • image-versie (codeberg.org/forgejo/forgejo:<versie>).

F2. Configpaden in de container

docker inspect <forgejo> --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}'
docker exec <forgejo> 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

docker exec <forgejo> 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 <config> --type zip -f - — codebases, attachments, hooks, LFS metadata, etc.
  2. Separate pg_dump <forgejo_db> — 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.

# 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:<vul-versie-in>
    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

# 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)

# 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 sectie 2a primair.

G3. Forgejo herstellen

Volg F5 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):

    brew install restic jq
    # Maintenance-key uit password manager
    export B2_ACCOUNT_ID=<maintenance-key-id>
    export B2_ACCOUNT_KEY=<maintenance-app-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):

    restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --dry-run
    
  3. Daadwerkelijke prune (vereist bypassGovernance capability — alleen via maintenance-key):

    restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --prune
    
  4. Diepere check:

    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 <correct-user>
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 slaagtrestore-test.sh nasoverall_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.