Files
docs/services/backrest.md
T
fkrebs f1ed462c91 fix: resolve 7 of 8 backup blind spots (2026-05-28)
- Vaultwarden data moved to UNAS /services/vaultwarden (CT 104 local → NFS)
- Garage S3 pre-backup rsync hook (garage-prestage.sh) + path added to plan
- n8n pre-backup rsync hook (n8n-prestage.sh) + confirm path covered
- Paperless-ngx media path added to services-backup-plan
- Stale paths (arcane, arr-stack, gluetun) removed from plan
- Repo passwords confirmed rotated (not tapirnase); LiteLLM blind spot N/A
- CT 103 SSH key deployed to CT 104 + CT 109 for prestage hooks
- pocketid-prestage.sh updated from CT 110 (destroyed) → CT 109
- Coverage map updated with new entries
2026-05-28 01:35:31 +02:00

17 KiB
Raw Blame History

Backup Strategy

Single Backrest instance on CT 103 (192.168.1.3:9898) backing up offsite to JottaCloud via rclone. CT 103 has UNAS NFS mounted at /mnt/pve/unas, so it can read all service data without SSH-ing other hosts.

Goal: every piece of critical state has an offsite copy. CT 104 loss is recoverable within hours; UNAS loss is recoverable (slower) from JottaCloud.


Current state

Backrest (CT 103) — Phase 1b live since 2026-05-21

Item Value
UI http://192.168.1.3:9898 (LAN-only, auth disabled)
Config /opt/backrest/config/config.json (timestamped .bak files on every edit)
rclone remote jottacloud: Archive section (default device/mountpoint); token at /root/.config/rclone/rclone.conf
Offsite plan JottaCloud Unlimited €9.91/mo (Norway, EEA) — see provider comparison
BW throttle RCLONE_BWLIMIT=05:30,7.5M 01:00,20M (20 MB/s overnight, 7.5 MB/s daytime)

Repos (both autoInitialize: true; passwords rotated 2026-05-28 — stored in Vaultwarden):

Repo Target Prune Check
services-repo rclone:jottacloud:services weekly Sun 05:00, ≤10 % unused monthly, 10 % subset
media-repo rclone:jottacloud:media monthly, ≤10 % unused monthly, 10 % subset

Plans:

Plan Repo Schedule Retention Paths
services-backup-plan services-repo 0 1 * * 1-5 weekdays 01:00 7d · 4w · 6m · 1y 14 paths (below)
media-backup-plan media-repo 30 1 * * 1-5 weekdays 01:30 4w · 6m · 1y /mnt/pve/unas/media/images/library
video-projects-plan media-repo nightly 02:00 per config /mnt/pve/unas/media/video-projects

Services plan paths (all under /mnt/pve/unas/, updated 2026-05-28):

services/coder                services/gitea               services/karakeep
services/memos                services/n8n                 services/nextcloud
services/pocketid-backup      services/shared-db-garage    services/traccar
services/vaultwarden
backup/home-assistant         backup/immich                backup/nextcloud
media/documents/public/paperless-ngx

Pre-backup hooks (run at CONDITION_SNAPSHOT_START):

  • pocketid-prestage.sh — exports Pocket-ID zip + keys from CT 109 → UNAS
  • garage-prestage.sh — rsyncs CT 104 Garage S3 data/meta → services/shared-db-garage
  • n8n-prestage.sh — rsyncs CT 104 n8n config/nodes/storage → services/n8n

Hook scripts at /opt/backrest/scripts/ on CT 103. CT 103 SSH key deployed to CT 104 + CT 109 2026-05-28.

services/shared-db was removed when CT 113 came online — postgres is now WAL-G → Garage S3 → JottaCloud. services/pocketid removed 2026-05-26 — Pocket-ID moved to CT 109 local FS (not UNAS); now covered by ops-backup.timer → Garage S3.

JottaCloud web UI tip: rclone writes to the Archive section. The default landing page shows only Sync + Backup. Browse to https://www.jottacloud.com/web/archive to see the restic repos.

Postgres (CT 113) — Phase 2 live since 2026-05-21

Dedicated db LXC at 192.168.1.6:5432 running postgres:17 in Docker, pgAdmin on :5050. WAL-G archives continuously to Garage S3 (ct113-pg-backup on CT 104):

  • archive_mode = on, archive_command = 'wal-g wal-push %p'
  • Daily wal-g backup-push cron at 02:00
  • DBs hosted: vaultwarden, paperless, litellm, memos, n8n, gitea, coder

Garage → JottaCloud offsite sync runs daily 02:30 on CT 103 via /usr/local/sbin/walg-offsite-sync.sh (read-only Garage key GKef577420aadd26d667f2ca4f; mirrors ct113-pg-backup, lobe-pg-backup, immich-pg-backupjottacloud:WAL-G/).

Exceptions (stay on original hosts):

DB Host Why
lobe-postgres (paradedb pg17) CT 104 Uses pg_search USING bm25 indexes; stock postgres 17 can't host. WAL-G → lobe-pg-backup + Backrest secondary on data dir.
Nextcloud AIO postgres CT 105 AIO manages it; Borg archives the whole stack to UNAS.
Immich postgres CT 104 Version-pinned by Immich. WAL-G → immich-pg-backup.

Coverage map

Host / data Method Offsite
CT 103 Backrest binary + config Manual (small) On-CT only
CT 104 Docker app state on UNAS services-backup-plan ✓ JottaCloud
CT 104 Vaultwarden data moved to UNAS /services/vaultwardenservices-backup-plan ✓ JottaCloud
CT 104 Garage S3 data garage-prestage.sh rsync → UNAS /services/shared-db-garageservices-backup-plan ✓ JottaCloud
CT 104 n8n config/encryption key n8n-prestage.sh rsync → UNAS /services/n8nservices-backup-plan ✓ JottaCloud
Paperless-ngx media UNAS /media/documents/public/paperless-ngxservices-backup-plan ✓ JottaCloud
CT 104 lobe-postgres (paradedb) WAL-G → Garage → JottaCloud sync
CT 104 Immich postgres WAL-G → immich-pg-backup → sync
CT 113 postgres (7 DBs) WAL-G → ct113-pg-backup → sync
CT 101 Shepard Source in Gitea (gitea path covers it)
CT 102 AdGuard Phase 3 git push → fkrebs/adguard-conf ✗ not deployed
CT 104 Gitea + Coder services/{gitea,coder} (UNAS; moved from CT 111 2026-05-26)
CT 105 Nextcloud user files services/nextcloud
CT 105 Nextcloud AIO volumes AIO Borg → /mnt/pve/unas/backup/nextcloud/ → Backrest
CT 108 Zoraxy DESTROYED 2026-05-28 — replaced by Traefik on CT 109
CT 109 Traefik config + acme.json Not backed up (LE can reissue; config in /opt/stacks/traefik/) ✗ low risk
CT 109 Portainer config Daily tar → Garage S3 ct109-portainer-backup → JottaCloud sync
CT 109 Pocket-ID Daily tar → Garage S3 ct109-portainer-backup (key ops-*.tar.gz) → JottaCloud sync via ops-backup.timer
CT 109 Infisical pg_dump in ops-*.tar.gz (same as above)
CT 110 Pocket-ID CT 110 destroyed 2026-05-26 — see CT 109 row above
CT 111 Gitea + Coder CT 111 destroyed 2026-05-26 — see CT 104 row above
CT 112 Infisical CT 112 destroyed 2026-05-26 — see CT 109 row above
CT 108 Zoraxy CT 108 destroyed 2026-05-28 — Traefik on CT 109 is replacement
VM 100 HAOS config Phase 3 git addon → fkrebs/ha-config ✗ not deployed
VM 100 HAOS daily tar HA → UNAS → Backrest
PVE host /etc/pve/ Phase 3 git push → fkrebs/pve-conf ✗ not deployed
UNAS media/images/library Phase 1a rclone syncjottacloud:Photos/ ✗ not deployed
UNAS personal data (documents, _sortMe, video-projects, …) Phase 1a rclone syncjottacloud:UNAS/ ✗ not deployed

Intentionally not backed up

Immich thumbnails / encoded video, ComfyUI / Speaches models, arr-stack metadata, Redis / Meili / Elastic caches, CT 101 dev volumes — all regenerable or re-downloadable.

Deferred — include if needed

Host Data Notes
CT 109 Prometheus TSDB, Grafana dashboards Low priority — metrics are ephemeral; dashboards re-exportable from Grafana; add to services plan if needed
CT 109 Portainer data WAL-Gstyle daily tar → ct109-portainer-backup Garage S3 → JottaCloud offsite sync ✓ (via walg-offsite-sync.sh)

Open work

Phase 1a — rclone sync for UNAS personal data

Two sync jobs on CT 103, Saturday 01:00:

  • Job 1: media/images/library/jottacloud:Photos/ (Immich originals, visible at jottacloud.com/photo).
  • Job 2: /mnt/pve/unas/jottacloud:UNAS/ (documents, video-projects, _sortMe, musical-sheets, audiobooks, ebooks, code; excludes Immich-generated, re-streamable media, torrents, services/ already in Backrest).

Initial upload ≈ 820 GB personal + 822 GB Immich (days). Script target /usr/local/sbin/unas-sync.sh. Full script in appendix.

Tradeoff vs Restic: no point-in-time versions; deletions propagate. Acceptable for personal media.

Phase 3 — Config-to-git for infrastructure

Daily 03:00 cron on each host pushes config to a private Gitea repo. Repos already exist:

  • fkrebs/zoraxy-conf (CT 108 — /opt/zoraxy/conf/)
  • fkrebs/adguard-conf (CT 102 — AdGuardHome.yaml)
  • fkrebs/pve-conf (PVE — /etc/pve/, excludes priv/, *.key, authkey.pub*)
  • fkrebs/ha-config (VM 100 — /config/, excludes secrets.yaml, .storage/)

Script template in appendix.

Blind spots

# Severity Issue Status
1 CRITICAL Vaultwarden data on CT 104 local FS FIXED 2026-05-28 — volume moved to /mnt/pve/unas/services/vaultwarden/; path added to plan
2 HIGH Garage S3 data on CT 104 local FS FIXED 2026-05-28garage-prestage.sh rsyncs to UNAS before each backup; path services/shared-db-garage added to plan
3 MEDIUM Paperless-ngx media not in any plan FIXED 2026-05-28media/documents/public/paperless-ngx added to services-backup-plan
4 MEDIUM n8n encryption key on CT 104 local FS FIXED 2026-05-28n8n-prestage.sh rsyncs config/nodes/storage to UNAS before each backup
4b MEDIUM Nextcloud AIO Borg passphrase only in container env Store BORG_PASSWORD in Vaultwarden — requires UI
5 MEDIUM Backrest repo passwords were tapirnase FIXED — passwords already rotated to complex strings in config.json
6 LOW litellm DB placeholder password N/A — LiteLLM decommissioned 2026-05-26; DB dropped
7 LOW Migration SQL dumps at /mnt/pve/unas/dump/ N/A — no SQL dump files found; dumps were already cleaned up
8 LOW Stale paths arcane, arr-stack, gluetun in plan FIXED 2026-05-28 — removed from services-backup-plan

Operations

Restore

UI: Repos → snapshots → Browse → file → Restore.

CLI on CT 103:

restic -r rclone:jottacloud:services snapshots
restic -r rclone:jottacloud:services restore latest \
  --target /restore \
  --include /mnt/pve/unas/services/vaultwarden

Monitoring (planned)

In Backrest UI → each repo → Hooks:

  • CONDITION_BACKUP_ERROR / CONDITION_CHECK_ERROR → POST Gotify priority 8
  • CONDITION_BACKUP_SUCCESS → POST Gotify priority 3
curl -s -X POST 'http://gotify:80/message?token=TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Backrest: {{.Plan}}","message":"{{.Summary}}","priority":3}'

Applying a new config

# on CT 103
systemctl stop backrest
cp /opt/backrest/config/config.json /opt/backrest/config/config.json.bak.$(date +%Y%m%d-%H%M%S)
# paste new config.json
systemctl start backrest

History

Provider comparison

Researched 2026-05-21. Sized for ~3 TB/mo.

Provider ~3 TB/mo Backend EU DC Egress Verdict
JottaCloud Unlimited €9.91 flat (unlimited) rclone native Norway (EEA) Free Chosen
Hetzner BX31 €20.80 flat (10 TB) SFTP/WebDAV DE, FI Free 2.5× the price, capped
Backblaze B2 ~$18 pay-per-GB S3 Frankfurt Free (≤3× stored) US CLOUD Act risk
Cloudflare R2 ~$45 pay-per-GB S3 EU auto Zero Expensive at scale; no EU residency lock
Wasabi ~$2124 S3 FRA/AMS Free (≤ stored) 90-day min billing per object → Restic prune disaster
Storj DCS ~$30 S3 EU-geofenced 1× free Complex, $5 minimum
pCloud €399 one-time / 2 TB WebDAV Luxembourg Free WebDAV too slow
Infomaniak kDrive ~€36+ / 3 TB WebDAV Switzerland Free WebDAV only; non-EU
Proton Drive rclone beta Switzerland Free rclone backend broken since late 2025

Phase 2 — postgres consolidation onto CT 113 ( done 2026-05-21)

Decision: provision a dedicated db LXC (CT 113, 192.168.1.6) running postgres in Docker rather than reuse shared-postgres on CT 104. Direct-to-target avoided migrating twice (CT 111 → CT 104 → CT 113).

Specs: Debian 12 unprivileged, 2 GB RAM, 2 cores, 20 GB local-zfs, mp0=/mnt/pve/unas. Stack in Gitea fkrebs/stacks-db. pgAdmin pre-registers postgres via pgadmin-servers.json.

Migration order: vaultwarden → paperless → litellm → memos → n8n → gitea → coder. Procedure per DB:

docker exec <container> pg_dump -U <user> <db> > /mnt/pve/unas/dump/<db>-migration.sql
psql -U postgres -c "CREATE USER <user> WITH PASSWORD '...'; CREATE DATABASE <db> OWNER <user>;"
psql -U <user> <db> < /mnt/pve/unas/dump/<db>-migration.sql
# Update connection strings → 192.168.1.6:5432, restart service, verify, remove old PG container + volume

Stale DBs dropped from shared-postgres post-migration: daytona, lobechat (duplicate), paradedb (duplicate).

WAL-G enabled 2026-05-21: archive_mode = on, archive_command = 'wal-g wal-push %p' via ALTER SYSTEM; first base backup verified (base_000000010000000000000012). Daily wal-g backup-push cron at 02:00. Garage → JottaCloud offsite sync added at 02:30.

Phase 1a rclone sync script

#!/bin/bash
set -e
LOG=/var/log/rclone-unas-sync.log

# Job 1: Immich originals → JottaCloud gallery
rclone sync /mnt/pve/unas/media/images/library/ jottacloud:Photos/ \
  --transfers=4 --checkers=8 \
  --log-file=$LOG --log-level INFO

# Job 2: all other irreplaceable personal data
rclone sync /mnt/pve/unas/ jottacloud:UNAS/ \
  --transfers=4 --checkers=8 \
  --exclude "media/images/library/**" \
  --exclude "media/images/upload/**" \
  --exclude "media/images/thumbs/**" \
  --exclude "media/images/encoded-video/**" \
  --exclude "media/images/profile/**" \
  --exclude "media/images/backups/**" \
  --exclude "media/movies/**" \
  --exclude "media/emulation/**" \
  --exclude "media/Torrents/**" \
  --exclude "media/music/**" \
  --exclude "media/podcasts/**" \
  --exclude "services/**" \
  --exclude "backup/**" \
  --exclude "backup-staging/**" \
  --exclude "test_perm" \
  --log-file=$LOG --log-level INFO

Cron (Saturday 01:00): 0 1 * * 6 /usr/local/sbin/unas-sync.sh

Phase 3 config-to-git scripts

Same pattern on each host. Replace GITEA_TOKEN with the value from /opt/stacks/ai/.env or a dedicated scoped Gitea token.

CT 108 — Zoraxy (/usr/local/sbin/zoraxy-conf-backup.sh):

#!/bin/bash
set -e
REPO_URL="https://fkrebs:GITEA_TOKEN@git.nuclide.systems/fkrebs/zoraxy-conf.git"
WORK="/opt/zoraxy/conf"
git -C "$WORK" init -b main -q 2>/dev/null || true
git -C "$WORK" remote set-url origin "$REPO_URL" 2>/dev/null \
  || git -C "$WORK" remote add origin "$REPO_URL"
git -C "$WORK" add -A
git -C "$WORK" commit -q -m "auto: $(date -u +%Y-%m-%dT%H:%M:%SZ)" 2>/dev/null || true
git -C "$WORK" push -q origin main 2>&1 | grep -v "Everything up-to-date" || true

CT 102 — AdGuard — same template, copy /opt/AdGuardHome/AdGuardHome.yaml into /tmp/adguard-conf-work checkout first.

PVE host — same template, rsync -a --exclude='priv/' --exclude='*.key' --exclude='authkey.pub*' /etc/pve/ /tmp/pve-conf-work/ then commit.

VM 100 — HA — native Git Pull addon pushing /config/ (exclude secrets.yaml, .storage/) to fkrebs/ha-config. HA daily tars already covered by services-backup-plan via /mnt/pve/unas/backup/home-assistant/.

Cron on each host: 0 3 * * * /usr/local/sbin/<host>-conf-backup.sh

Why Wasabi was rejected

Restic creates many small pack files during normal operation. Wasabi charges 90 days of storage per object regardless of deletion — every restic forget --prune generates surprise costs. Well-documented Restic-on-Wasabi trap.

Why lobe-postgres stays on CT 104

Migration 0093_add_bm25_indexes_with_icu.sql creates USING bm25 indexes on 7 tables (agents, topics, files, knowledge_bases, user_memories, chat_groups, user_memories_contexts). bm25 is paradedb-only (pg_search extension); stock postgres 17 has no such index access method and the migration fails. Container stays paradedb/paradedb:latest-pg17. WAL-G archives to lobe-pg-backup; Backrest secondary copies the data dir.

UNAS data inventory (basis for Phase 1a sizing)

Path Size Notes
media/documents/ 9.5 G Personal documents
media/video-projects/ 425 G Creative work, irreplaceable
media/musical-sheets/ 23 G
media/audiobooks/ 23 G
media/ebooks/ 6.8 G
media/3d-prints/ 65 M
media/Recipes/ 113 M
_sortMe/ 335 G images/ 171 G, work Flo/ 147 G, Anne/ 17 G
code/ 6.7 M
Excluded media/movies/ 118 G re-streamable
Excluded media/emulation/ 57 G re-downloadable
Excluded media/Torrents/ 19 G temporary
Excluded media/music/, media/podcasts/ re-streamable