Files
docs/services/backrest.md
T
fkrebs 24c7d56a1b backup: update coverage map and blind spots (2026-05-28)
- CT 108 Zoraxy decommissioned -> Traefik on CT 109
- Add critical blind spots: Vaultwarden (local FS, not backed up),
  Garage S3 data (WAL-G archive store, not backed up), Paperless media
- Fix services plan path list to match actual Backrest config.json
- Mark stale plan paths: arcane, arr-stack, gluetun
- Note Garage config incident: garage.toml became a directory after
  Portainer redeploy; fixed 2026-05-28 by manual replace + layout apply
2026-05-28 01:15:29 +02:00

319 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](#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 currently `tapirnase` — see blind spot #5):
| 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 | 15 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/`, verified 2026-05-28):
```
services/arcane services/arr-stack services/coder
services/gitea services/gluetun services/karakeep
services/memos services/n8n services/nextcloud
services/pocketid-backup services/traccar
backup/home-assistant backup/immich backup/nextcloud
```
> ⚠️ `services/arcane`, `services/arr-stack`, `services/gluetun` are stale paths (services decommissioned or moved) — harmless but should be pruned from the plan.
`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-backup``jottacloud: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 `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 sync``jottacloud:Photos/` | ✗ not deployed |
| UNAS personal data (`documents`, `_sortMe`, `video-projects`, …) | Phase 1a `rclone sync``jottacloud: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](#phase-1a-rclone-sync-script).
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](#phase-3-config-to-git-scripts).
### Blind spots
| # | Severity | Issue | Fix |
|---|---|---|---|
| 1 | **CRITICAL** | **Vaultwarden data at `/opt/stacks/vaultwarden/data` (CT 104 local FS) — not on UNAS, not in any plan.** Password vault loss is unrecoverable. | Move volume to `/mnt/pve/unas/services/vaultwarden/` and add that path to `services-backup-plan`. |
| 2 | HIGH | **Garage S3 data at `/opt/stacks/shared-db/garage/` (CT 104 local FS) — contains all WAL-G DB archives.** If CT 104 disk dies before WAL-G → JottaCloud sync runs, all DB backups are gone. | Add `/opt/stacks/shared-db/garage/` to a Backrest plan, OR verify JottaCloud sync runs frequently enough. Note: Garage had a config incident 2026-05-28 (toml path became a directory after Portainer redeploy — fixed manually). |
| 3 | MEDIUM | Paperless-ngx media at `/mnt/pve/unas/media/documents/public/paperless-ngx/` not in any Backrest plan. | Add to `services-backup-plan` paths. |
| 4 | MEDIUM | n8n encryption key in `/opt/stacks/n8n/data/` on CT 104 local FS — not in any plan. If CT 104 dies, DB restore is unusable. | Move n8n data volume to `/mnt/pve/unas/services/n8n/` (already in plan). |
| 4b | MEDIUM | Nextcloud AIO Borg passphrase only in container env. Borg repo encrypted — without it, restore impossible. | Store `BORG_PASSWORD` in Vaultwarden. |
| 5 | MEDIUM | Both Backrest repo passwords are `tapirnase`. | Rotate before first scheduled run completes; `restic key passwd` re-encrypts in place. Store in Vaultwarden. |
| 6 | LOW | `litellm` DB password is the placeholder literal `litellm_password_here`. | Generate real password; update `ai/.env`, `litellm-config/config.yaml`, CT 113 user. |
| 7 | LOW | Migration dumps at `/mnt/pve/unas/dump/*-migration-20260521.sql` not in any plan. | Decide: keep as manual archive or delete now that WAL-G is archiving. |
| 8 | LOW | Stale paths in `services-backup-plan`: `arcane`, `arr-stack`, `gluetun` (services gone/moved). | Remove from plan to reduce noise and snapshot size. |
---
## Operations
### Restore
UI: **Repos → snapshots → Browse → file → Restore**.
CLI on CT 103:
```bash
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
```bash
# 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:
```bash
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
```bash
#!/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`):
```bash
#!/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](https://github.com/home-assistant/addons/tree/master/git_pull) 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 |