Five sets, 14/14 successful runs in 14 days, and a file restored byte-identical from the data repo -- the first time a restore has ever been demonstrated here. What the failure matrix exposes: notify_failure is a no-op (NOTIFY_ON_FAILURE=true, webhook commented out) so a failed backup tells nobody; NOTHING leaves the box (Longhorn's target is DooPlex itself over NFS, the only outbound-looking cron pulls inbound for another project); the backup tree is a single writable path with no append-only; two sets are same-disk (.claude-memory and the postgres dumps, whose source sits inside the backup tree); Longhorn retains 1 generation; secrets/restic-repo has never held a snapshot because backup-secrets.sh never calls restic; and /opt/backup/docs/BACKUP-RESTORE.md does not exist though the unit advertises it. NOT a finding: the restic passphrase. On-box copy is on a different disk from the backups and the operator holds an offline copy out of band, so disk loss is recoverable. The residual is only that it is operator-held rather than system-held escrow. A stale lock in the data repo is MINE, from the previous task's verification. Fenced from clearing it; recorded with its impact and the one command that fixes it.
16 KiB
RECON — what DooPlex's own backup actually protects
2026-08-06, read-only. Nothing was modified, stopped, pruned or deleted. One scratch restore into
/tmp/recon-restore-20260806-121056, deleted afterwards. No customer machine touched.
State at start: no backup running, no restic process. dooplex-backup.timer enabled + active,
next fire 2026-08-07 03:18:49. The service showing disabled is expected for a timer-driven unit
and is not a finding — recorded here so nobody chases it later. Last run 03:06:07 → 03:12:51,
Result=success, ExecMainStatus=0.
1. Set inventory
Five sets run from backup-all.sh. Two more things write into the same tree and are not part of
that service. Every "last success" below is from the journal or the repo, not from a config line.
| Set | Source paths | Source dev | Mechanism | Destination | Dest dev | Schedule | Last success | Snaps | Oldest → newest | Verified? | Off-site |
|---|---|---|---|---|---|---|---|---|---|---|---|
| K3s State | /var/lib/rancher/k3s/server/{db,token,node-token,cred,tls} · /etc/rancher/k3s |
sdb1 |
restic | backup/k3s/restic-repo |
sda1 |
nightly 03:00 | 2026-08-06 03:06:07 | 26 (13+13) | 03-31 → 08-06 | yes — check --read-data-subset=5% every run |
no |
| Kubernetes Secrets | k8s API (all secrets + configmaps, per-namespace) | n/a | GPG files — NO restic | backup/secrets/exports/ |
sda1 |
nightly | 2026-08-06 03:06 | 0 | 07-06 → 08-06 (96 files, 134 MB, 30-day find -mtime +30 -delete) |
no | no |
| Git Manifests | Gitea repos via API token | n/a | clone/mirror | backup/homelab-manifests/ |
sda1 |
nightly | 2026-08-06 03:06 | — | — | no | no |
| User Data | /mnt/4_hdd/data · /mnt/5_hdd/felhom.eu/git/.claude-memory |
sdg2 · sda1 |
restic | backup/data/restic-repo |
sda1 |
nightly | 2026-08-06 10:51:17 (manual) / 03:0x nightly | 14 | 03-31 → 08-06 | no | no |
| PostgreSQL Dumps | /mnt/5_hdd/backup/postgresql/dumps |
sda1 |
restic | backup/postgresql/restic-repo |
sda1 |
nightly | 2026-08-06 03:09:52 | 11 | 06-25 → 08-06 | no | no |
| (separate) Longhorn PVCs | 77 volumes, replicas under /var/lib/longhorn |
sdb1 |
Longhorn backupstore | nfs://192.168.0.180:/mnt/5_hdd/backup/longhorn-pvc |
sda1 |
daily 04:00 + weekly, retain=1 | 2026-08-06 04:10 (sync 10:08) | 77 vols / 88 GB | — | no | no |
| (separate) jarrs.eu | Hetzner → DooPlex | remote | rsync inbound | backup/jarrs.eu/ |
sda1 |
cron 04:00 | 2026-08-06 04:00 | 33 files / 19 MB | — | n/a | inbound only |
Dormant destination directories (nothing writes them any more): demo-felhom 25 GB, newest
2026-07-10 (27 days); sparkyfitness 652 KB, newest 2026-06-24 (43 days);
secrets/restic-repo — initialised 2026-01-05, zero snapshots ever.
Tree total ≈ 167 GB. /mnt/5_hdd is 24% used, 6.6 TB free — no space pressure.
Per-set reliability: 14 runs in 14 days, all five sets ✓ completed successfully every run. The
routine works; the questions below are about what it would survive, not whether it runs.
2. The same-disk answer, stated directly
The destination for every set is /dev/sda1 (WDC WD103KFBX-68CCLN0, serial WD-B1036H8J).
| Set | Source device | Same disk as destination? |
|---|---|---|
| K3s State | sdb1 (Apacer SSD, root) |
No — cross-disk |
| Longhorn PVCs | sdb1 (replicas under /var/lib/longhorn) |
No — cross-disk |
User Data → /mnt/4_hdd/data |
sdg2 (ST3000VX010 Z6A07P2G) |
No — cross-disk |
User Data → .claude-memory |
sda1 |
YES — same physical disk |
| PostgreSQL Dumps | sda1 (/mnt/5_hdd/backup/postgresql/dumps) |
YES — and the source is inside the backup tree |
| Kubernetes Secrets | k8s API (no disk source) | destination sda1, single copy |
| Git Manifests | Gitea (which is a Longhorn PVC on sdb1) |
No — cross-disk |
The operator's suspicion was right, and it is narrower than feared. Two sets are same-disk:
the memory store (added 2026-08-06) and the PostgreSQL dumps. The postgres case is the stranger of
the two — the dump directory lives inside /mnt/5_hdd/backup/, so restic is deduplicating a copy
of files that are already in the backup tree, onto the same disk. It protects against a bad dump, not
against losing sda1.
Everything else is genuinely cross-disk. But cross-disk is not off-site — see §4.
3. Failure matrix
"Demonstrated" means someone has actually done it and checked the result. Only one cell in this column is a yes, and it was earned today.
| Failure | What is lost | What is recoverable | From where | How long | Ever demonstrated? |
|---|---|---|---|---|---|
| A file deleted by mistake | the file | all of it, to the previous night (or to the 6-hourly dump for a DB) | the relevant restic repo / Longhorn backup on sda1 |
minutes | YES — today. A file was restored from the data repo and matched the live original byte for byte (§5) |
sda1 (5_hdd) fails |
every backup, all of it — all four restic repos, the Longhorn backupstore, the secrets exports, the manifest mirror | the live system keeps running: k3s, Longhorn replicas, Gitea, the registry and the hub are all on sdb1. /mnt/4_hdd/data survives on sdg2. .claude-memory is lost (source and only copy were both on sda1) |
nothing — there is no second copy of the backups | n/a for the backups; the live system is unaffected | no |
sdg2 (4_hdd) fails |
the live /mnt/4_hdd/data |
all of it | data restic repo on sda1 |
hours (405 GiB) | partially — the single-file probe worked; a full-set restore has never been run |
| DooPlex's board or PSU dies | nothing permanently, if the disks survive | everything, by moving the disks or rebuilding and reattaching | sda1 + sdb1 in hand |
days — and see the note below | no |
| The flat burns | everything — every disk is in the same box, and nothing leaves it | nothing from this backup system. Separately: customer data has a genuine off-site tier (Hetzner + ep0); the felhom repos exist on Gitea, which is on DooPlex; some repos have working clones on this same machine | — | — | no |
A bad script or ransomware writes over /mnt/5_hdd/backup/ |
every backup at once | the live system only | nothing — the tree is a single writable path, and the restic repos are not --append-only |
n/a | no |
The note on "board or PSU dies": recovery needs both the disks and the restic passphrase. The
on-box copy is /etc/backup/restic-password on sdb1 — a different disk from the backups, which
is good. The operator has confirmed an offline copy exists out of band, so this row is
recoverable rather than fatal. See §6 for the one residual that remains.
4. Does anything leave the box?
No. Nothing. Stated without softening, because it is the row the matrix turns on.
- No
rclone, no remote restic repository, no rsync-to-remote, no S3/B2/SFTP target anywhere in/opt/backup/scripts/. - The only outbound-looking job,
sync-hetzner-backups.sh(cron 04:00), is inbound: it pulls jarrs.eu database backups from Hetzner to DooPlex. It is a different project and it moves data the wrong way to help here. - Longhorn's backup target is
nfs://192.168.0.180:/mnt/5_hdd/backup/longhorn-pvc— DooPlex writing to itself over NFS, landing onsda1.
The felhom product has a real off-site chain (Hetzner Storage Box + ep0/PBS-DR) for customer boxes. DooPlex, which runs the hub that manages that chain, has none of it.
5. The restore probe
| Repo | /mnt/5_hdd/backup/data/restic-repo |
| Snapshot | b587f775 (2026-08-06 10:51:17) |
| File | .claude-memory/hetzner-storagebox-api.md (3.812 KiB) |
| Restored sha256 | 5b4d66b0341fe38c21b3a77f2f0af59ad4389fc21a8ce941c729fb807a3dd953 |
| Live sha256 | 5b4d66b0341fe38c21b3a77f2f0af59ad4389fc21a8ce941c729fb807a3dd953 |
| Result | MATCH — a file came back, byte for byte |
Scratch directory /tmp/recon-restore-20260806-121056 deleted.
A first attempt used MEMORY.md and the hashes differed — correctly: the restored copy was
17,977 bytes, the file's exact size at snapshot time, and the live file had since been edited to
18,078. That is the snapshot behaving as a point-in-time copy, not a fault, and the probe was redone
against a file this session never touched.
6. The password question
Measured on the box: /etc/backup/restic-password — 45 bytes, 0600 root:root, on /dev/sdb1
(the root SSD, not the disk holding the backups). It is not a source path in any set. The same
file is the passphrase for the GPG-encrypted secrets exports (--passphrase-file "${RESTIC_PASSWORD_FILE}"),
so one secret opens both the restic repos and the secrets archive.
Off the box: the operator confirms an offline copy exists out of band. A recon on the machine
cannot observe that, and without it this section would have concluded that an sdb1 loss makes every
repo permanently unopenable — which would have been wrong. The failure matrix above is written on the
corrected basis.
The residual, stated narrowly and without alarm: the offline copy is operator-held, not system-held. The felhom product solves the equivalent customer problem with hub-vaulted escrow and a zero-knowledge recovery code; the operator case has no equivalent mechanism, so recovery time in the "board dies" and "flat burns" rows depends on that copy being current and findable by someone other than the operator. Worth confirming both; not worth engineering around.
7. Coverage gaps
| Item | Status | Evidence |
|---|---|---|
| Gitea repositories | covered (twice) | gitea-data is a 50 Gi Longhorn PVC → backupstore; plus backup-manifests.sh mirrors repos via the API to backup/homelab-manifests/ |
| Container registry | covered | part of gitea-data (Gitea's built-in registry) |
| Gitea database | covered | CNPG → 6-hourly dumps → postgresql restic repo |
| k3s state | covered, and the only verified set | db, token, node-token, cred, tls + /etc/rancher/k3s, check --read-data-subset=5% each run |
| Longhorn volumes | covered, thinly | 77 volumes, but retain=1 — one generation per volume. A corruption noticed after the next run has no earlier copy |
| Hub database | covered | hub-data (1 Gi) is a Longhorn PVC |
| TLS material / secrets | partially | exported and GPG-encrypted nightly, 30-day retention, on sda1 only, never entered into any restic repo — backup-secrets.sh contains no restic call at all |
/opt/backup itself |
NOT covered | on sdb1, in no set. The scripts that perform every backup are backed up by nothing (R-231) |
~/.claude/settings.json |
NOT covered | on sdb1, in no set. Now reconstructible via install_workspace.py |
| ISO build inputs | NOT covered | /mnt/5_hdd/felhom.eu/build, on sda1, in no set |
| Local git clones | NOT covered — and circular | /mnt/5_hdd/felhom.eu/git on sda1, in no set. Recoverable from Gitea — which runs on this machine. If DooPlex is gone, both copies are gone together |
.claude-memory |
covered, same-disk only | §2 |
8. Observations — noticed, not acted on
/opt/backup/docs/is EMPTY. The systemd unit advertisesDocumentation=file:///opt/backup/docs/BACKUP-RESTORE.mdand that file does not exist. The restore procedure you would reach for while the machine is on fire is absent, and the unit points at it anyway.notify_failureis a no-op.NOTIFY_ON_FAILURE="true"butNOTIFY_WEBHOOK_URLis commented out, and the function requires it to be non-empty. A failed backup notifies nobody. The five sets have succeeded 14/14, so this has never mattered yet — which is exactly when it is cheapest to fix.secrets/restic-repoexists and has never held a snapshot. A directory that looks like a backup and is not one.backup-secrets.shnever calls restic; its only match for the word is a comment about the password file.- A stale lock is sitting in the
datarepo, and it is MINE.pid 3524524, non-exclusive, created 11:20:26, process gone — left by atimeout-wrappedrestic lsduring the previous task's verification. I am fenced from clearing it (§4 of the task) and have not. Its impact is concrete:restic backupneeds only a non-exclusive lock and will succeed, butrestic checkalready refused (§ the check run below), andapply_retention→forget --pruneneeds an exclusive lock. Withset -euo pipefailand theERRtrap, tonight's User Data set would therefore fail — and per observation 2, silently. One command clears it:sudo RESTIC_PASSWORD_FILE=/etc/backup/restic-password restic -r /mnt/5_hdd/backup/data/restic-repo unlock - Integrity checks:
k3sandpostgresqlpassrestic checkcleanly;secretsis empty so passes trivially;datacould not be checked because of observation 4. Onlyk3sis verified on a schedule — the others have never been verified except by hand. - Two dormant destination directories hold 25 GB (
demo-felhom, last written 2026-07-10) and 652 KB (sparkyfitness, 2026-06-24). Nothing writes them; nothing prunes them. - The postgres dumps are produced by a k8s CronJob (
database-system/postgresql-backup, every 6 h) that the backup service does not control.backup-postgresql.shrefuses only if the directory is missing — if the CronJob stopped, the set would keep succeeding over stale dumps. Currently fresh: newest dump20260806-100000at 12:00 today. restic check --read-data-subsetwas not run beyond whatbackup-k3s.shdoes itself, per the task's fence. No repository has ever had its data blocks read back in full.
9. Recommendations, ranked — recommend only, nothing implemented
| # | Recommendation | Cost | Failure it removes |
|---|---|---|---|
| 1 | Clear the stale lock in the data repo (command in §8.4) |
one command | tonight's User Data set failing, silently |
| 2 | Give notify_failure somewhere to go — the project already has a working transactional mail path (Resend, used by CI and the hub) |
~10 lines | a backup failing and nobody learning it. This is the cheapest item here and the one that makes every other failure visible |
| 3 | Get one copy off the box. Even one set — k3s + secrets + postgres dumps is ~1.2 GB — to the Storage Box or ep0 | small; the credentials and the tooling already exist | the "flat burns" row, which is currently total loss |
| 4 | Make /mnt/5_hdd/backup non-trivially destroyable — restic --append-only via a REST/rclone-served repo, or a second target the writer cannot reach |
moderate | the ransomware / bad-script row |
| 5 | Move the postgres dump directory out of the backup tree, and add .claude-memory to a cross-disk destination |
small | the two same-disk cases in §2 |
| 6 | Raise Longhorn retain above 1 |
disk only, and there is 6.6 TB free | a corruption noticed one day late having no earlier copy |
| 7 | Write /opt/backup/docs/BACKUP-RESTORE.md, or drop the Documentation= line |
an hour | reaching for a restore procedure that is not there |
| 8 | Do one full-set restore drill and record it | half a day | the entire "Ever demonstrated?" column being empty |
| 9 | Put the secrets exports into their empty restic repo, or delete the repo so it stops looking like a backup | small | a directory that reads as protection and is not |
10. The one-sentence answer
The backup runs reliably, restores a file correctly, and would survive any single disk failing — but every copy it makes lives in one box, and if that box is gone, so is all of it.