Backups¶
Backups work, but nothing runs them on a timer
Audited 2026-08-08, then re-verified the same day against a real run.
The role works. Run 667 completed ok=38 changed=19 failed=0 and wrote real
archives for 22 of 24 targets — plex 12G, radarr 1.7G, receipts 1.6G, lidarr 745M,
sonarr 435M, gitea-data 266M, slskd 152M, pihole 110M, uptime-kuma 75M, aurral 62M,
navidrome 14M, tautulli 6.7M, prowlarr 4.9M, qbittorrent 4.1M, swag 3.9M,
emulatorjs 2.4M, sabnzbd 333K, overseerr 249K, openbao 109K raft snapshot,
lazylibrarian 45K, tailscale 3.7K.
But it only runs when someone pushes to roles/heezy-backup/**.
playbook-heezy-backup-execution.yml has no schedule: block, so the "Daily 2am
UTC" tier below has never run on a timer. Before run 667 the previous execution was
2026-07-28 — an 11 day gap with no backups at all, and that earlier run failed part
way through, leaving the 23 k8s/<service>/ directories empty. That empty state is
what the first pass of this audit mistook for a permanent defect.
Two things still need fixing:
- Add the schedule. See Remediation Backlog item 1 — and read the generator note first, these workflow files are generated.
- A volume backup cannot report failure. The
type: volumebranch ends with anecho, so the task'srcis always 0 regardless of whatkubectl exec … tardid. Both the per-target Discord notify and the run summary key offrc, so neither can ever fire. A 0-byte tarball reports success — which is how SABnzbd's 45-byte S3 backup went unnoticed.
One restore is now proven, not assumed. OpenBao was restored from its raft snapshot on 2026-08-16, after a node failure left its Longhorn volume with an empty filesystem. Full recovery from that day's 12:50 snapshot, nothing lost. It is the only target whose restore path has been exercised against real data. Procedure: Restore OpenBao From a Raft Snapshot.
Not a defect, checked: heezy-finance archives at 105 bytes because its /data
volume is genuinely empty. Its state lives in the heezy Postgres DB, which is
dumped separately.
Added 2026-08-16: the network device step was a fourth instance of the same class of bug and was missed by this audit, because it reported success. It had never captured a single config. Fixed for Cisco and FortiGate, still open for Ruckus. See Network Devices.
Intended Design¶
Two-tier backup system driven by the heezy-backup Ansible role.
| Tier | Schedule | Destination | Retention |
|---|---|---|---|
| Daily NFS | Daily 2am UTC (not wired up — see above) | big-boi:/nfs/heezy/backups/ |
7 days rolling |
| Monthly S3 | 1st of month 03:00 UTC | s3://heezy-backups-025066240222/heezy/ |
STANDARD_IA → 90 days, then expires |
Backups run as a Gitea Actions workflow (playbook-heezy-backup-execution.yml) on the act-runner. Each target is an independent task — one failure does not stop others. Discord is notified per failure and on final summary.
What Actually Exists Today¶
| Source | Covers | Cadence | Verified |
|---|---|---|---|
heezy-backup Ansible role |
22 of 24 targets, full list below | on push only — no timer | run 667, 2026-08-08 |
| OpenBao raft snapshot | the whole barrier: secrets, auth methods, policies, AppRoles | with the role above | restore performed for real, 2026-08-16 |
plex-config-backup CronJob (k8s, 04:00) |
plex-config PVC → /nfs/heezy/backups/plex/ |
daily, 3 kept | 2026-08-08 |
uptime-kuma-backup CronJob (k8s, 03:00) |
uptime-kuma-data → /nfs/heezy/backups/uptime-kuma/ |
daily, 3 kept | 2026-08-08 |
The two empty target dirs after run 667 are nzbhydra2 (decommissioned 2026-08-08 and
removed from the role, the directory is a leftover) and soularr (never in the target
list — see Uncovered by oversight).
The practical risk is not that backups fail, it is that they only happen when someone edits the role. Between runs there is no fresh copy of anything, and nothing alerts on the gap.
Longhorn Has No Native Backup Layer¶
Separately from the Ansible role, Longhorn itself is configured with:
backup-target: empty — no S3 or NFS backup target is registeredrecurringjobs.longhorn.io: none — no snapshot or backup schedules existbackups.longhorn.io: none
So there is no volume-level snapshot to fall back on either. The 3-way replication protects against a single disk or node failure; it does not protect against deletion, corruption, or a bad write, because all three replicas take the change.
What's Backed Up¶
K8s PVCs (kubectl exec tar, heezy namespace)¶
| Service | Path | Notes |
|---|---|---|
| overseerr | /config/db |
Full /config causes initialized: false on restore |
| radarr | /config |
|
| sonarr | /config |
|
| lidarr | /config |
|
| prowlarr | /config |
|
| sabnzbd | /config |
|
| qbittorrent | /config |
selector: app=qbittorrent-vpn, container: qbittorrent |
| tautulli | /config |
|
| navidrome | /data |
|
| pihole | /etc/pihole |
|
| uptime-kuma | /app/data |
|
| plex | /config |
Large — takes several minutes |
| lazylibrarian | /config |
|
| aurral | /app/backend/data |
|
| swag | /config |
|
| slskd | /app |
selector: app=slskd-vpn, container: slskd, excludes downloads |
| tailscale | /var/lib/tailscale |
|
| emulatorjs | /config |
|
| heezy-finance | /data |
|
| receipts | /data/receipts |
Receipt images — not in postgres |
| openbao | raft snapshot | namespace: openbao. Restore tested end to end 2026-08-16. See Restore OpenBao From a Raft Snapshot |
K8s PVCs (gitea namespace)¶
| Service | Path | Notes |
|---|---|---|
| gitea-data | /data |
Repos, attachments, LFS objects, app config |
Postgres¶
| Database | Method | Host |
|---|---|---|
heezy |
pg_dump | big-boi (192.168.1.21:5432), creds from OpenBao production/heezy/postgres/heezy-credentials |
gitea |
kubectl exec pg_dump into gitea-postgres pod |
gitea namespace, creds from OpenBao production/heezy/gitea/postgres |
n8n |
docker exec n8n-postgres pg_dump via SSH |
big-boi |
Big-boi Docker Compose (SSH tar)¶
| Service | Source Path |
|---|---|
| n8n data | /opt/big-boi-ai/n8n/ |
| open-webui | /opt/big-boi/open-webui/ (excludes cache) |
Other¶
| What | Method |
|---|---|
| Minecraft Bedrock | SSH tar to dmz-minecraft (192.168.3.13), stop/start around backup |
| Minecraft Java | SSH tar to dmz-minecraft-java (192.168.3.15) |
| Network configs | See Network Devices below. Cisco and FortiGate working as of 2026-08-16, Ruckus still not |
Network Devices¶
Handled by backup-network.sh.j2 in the heezy-backup role, writing to
/nfs/heezy/backups/network/.
| Device | Model / OS | Method | State |
|---|---|---|---|
| Cisco core switch (192.168.1.26) | WS-C3750G-48PS-S, IOS 12.2(44)SE4 | sshpass, interactive session, show running-config |
Working, ~291 lines |
| FortiGate (192.168.1.1:8443) | FGT60E, FortiOS 7.4.3 | REST API session cookie, monitor/system/config/backup?scope=global |
Working, ~420 KB |
| Ruckus AP (192.168.2.66) | Unknown | Unverified session cookie | Not working |
This step reported success while backing up nothing, from creation until 2026-08-16
/nfs/heezy/backups/network/ was empty from the day the directory was created
(2026-07-16) until the fix landed. The step printed "All network backups complete."
on every run. Three independent faults, each sufficient on its own:
- The secrets were read from paths that do not exist. The role looked under
production/heezy/network/{cisco,fortigate,ruckus}. All three returned 404. The credential facts rendered empty, so the template took its "not configured" branch, printed a skip notice, and exited 0. - The Cisco command could never have worked. It sent
terminal length 0 ; show running-configas an ssh exec command. This IOS has no;chaining, the service account lands at privilege 1 whereshow running-configis refused outright, and the storedenablesecret was never read.BatchMode=yesin the sharedSSH_OPTSalso suppressed the very password prompt sshpass exists to answer. - The failure could not have been detected. Success was keyed off ssh's exit status, which is 0 even when IOS rejects every command it was given. The error text was written into the output file and reported with a checkmark and a line count.
Fixed in ansible-heezy PR #44. Missing Cisco or FortiGate credentials now count as an error so the Discord notify fires, rather than skipping silently.
Cisco specifics¶
The switch is a 2008-era image and needs three accommodations that are easy to mistake for bugs:
- Privilege escalation is mandatory. The service account lands at privilege 1.
show running-configis rejected there withLine has invalid autocommand, which looks like a syntax error but is an authorization failure. Theenablesecret is the third key inproduction/heezy/cisco-switch/secret. - No command chaining, so no ssh exec. The backup drives an interactive session and pipes commands to stdin.
- Legacy crypto only. SHA-1 kex,
ssh-rsahost key, CBC ciphers. Modern OpenSSH refuses all of these by default and needs them re-enabled per connection.
Output is filtered to the text between Building configuration and end, then
validated for both anchors before the run is called a success. A wrong enable password
now produces a 0-line capture and a reported failure.
Why the switch is backed up rather than managed as code¶
Every other device in the lab is declared in Terraform or Ansible. The switch is not, and cannot easily be:
- No Terraform provider can reach it.
CiscoDevNet/iosxerequires RESTCONF, which needs IOS-XE 16.8 or newer. This image has no RESTCONF and no NETCONF. Genuine Terraform management means replacing the hardware. - Most of
cisco.ioswill not work either. The resource modules (ios_vlans,ios_l2_interfaces,ios_interfaces) gather state withshow running-config | section ..., and this IOS supports onlyinclude,exclude, andbegin.| sectionreturns% Invalid input detected. That leavesios_config, which only needs plainshow running-configand is genuinely idempotent, as the one usable path if config-as-code is ever wanted here.
So the switch is the one device where a hand-applied change is the normal case, which is exactly why a working config backup matters more here than anywhere else.
Ruckus is still not backed up¶
| Blocker | Detail |
|---|---|
| No credentials | Nothing exists in OpenBao at any path. There is no network/ tree at all |
| No reachable management interface | The AP (ec:8c:a2:27:3b:f0, DHCP-reserved at 192.168.2.66 on VLAN 200) is ARP-live but answers neither ICMP nor 443/80/8443 from the backup host. Not a firewall issue: policy 6 allow-servers-to-users permits SHARED to USERS for ALL |
| Unverified endpoints | The _login and _system/backup calls in the template are ZoneDirector-shaped and have never been exercised against this device. Unleashed uses a different scheme |
To finish it: store username, password, and ip at
secret/data/production/heezy/network/ruckus, confirm whether the device is Unleashed
or a standalone AP, and check whether the web UI answers from a host on VLAN 200.
NFS Layout¶
/nfs/heezy/backups/
k8s/
<service>/ # one tar.gz per run
gitea/
gitea-postgres-<date>.sql.gz
postgres/
heezy/
n8n/
minecraft/
bedrock/
java/
n8n/ # n8n data dir tars
open-webui/
network/
What's Not Backed Up¶
Excluded by design¶
| Excluded | Reason |
|---|---|
| NFS-backed media PVCs (movies, tv, music) | NFS share IS the storage |
| LGTM metrics (Mimir/Loki data) | ~115 GB, regenerates from scrapers |
| ollama | Model weights are re-downloadable |
| heezy-blog / heezy-info | Git is the source of truth |
| cs16-leaderboard | Stateless, no PVC |
| openclaw | Low value, excluded by design. Runs as openclaw-gateway on big-boi, not in k8s — the empty apps/openclaw dir in heezy-k8s was removed 2026-08-08 as a stale deploy-trigger leftover |
gluetun-data, qbittorrent-gluetun-data |
VPN session state, regenerates on connect |
Uncovered by oversight¶
These hold real state, have a Longhorn PVC, and are absent from the role's target list. Identified 2026-08-08; none were previously documented as gaps.
| PVC | Namespace | Size | What is lost |
|---|---|---|---|
heezy-projects-data |
heezy | 5Gi | heezy-projects application data |
heezy-maintenance-config |
heezy | 1Gi | heezy-maintenance application data |
swag-plex-config |
heezy | 5Gi | TLS certs + nginx config for the Plex reverse proxy (Let's Encrypt can reissue, but the config is hand-maintained) |
soularr-config |
heezy | 5Gi | Previously documented as "ConfigMap only, no state" — that is wrong, a 5Gi Longhorn PVC exists. Currently detached |
Known to be unprotected¶
| Excluded | Reason |
|---|---|
| steam-headless | 5.5 GB game saves on big-boi, not currently backed up |
| TrueNAS (192.168.1.200) | No backup coverage. NFS failure = loss of all NFS backup copies until the next S3 sync |
Manual Trigger¶
Gitea UI: heezy-admin/ansible-heezy → Actions → playbook-heezy-backup-execution.yml → Run workflow
Via API — supply a Gitea API token from OpenBao as $GITEA_TOKEN (see
Secrets & Access for the current path). Never inline the credential:
curl -sk -X POST \
"http://192.168.1.15:30360/api/v1/repos/heezy-admin/ansible-heezy/actions/workflows/playbook-heezy-backup-execution.yml/dispatches" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}'
Verify Backup Health¶
# Recent files on NFS (last 2 days)
ssh mcp-admin@192.168.1.21 "find /nfs/heezy/backups -name '*.tar.gz' -o -name '*.sql.gz' -o -name '*.snap' | xargs ls -lt 2>/dev/null | head -30"
# S3 listing
aws s3 ls s3://heezy-backups-025066240222/heezy/ --recursive --human-readable | sort | tail -20
Restoring Gitea¶
Postgres restore¶
# Get creds from OpenBao
VAULT_TOKEN=$(curl -sk -X POST "http://192.168.1.32:8200/v1/auth/approle/login" \
-H 'Content-Type: application/json' \
-d '{"role_id":"<role_id>","secret_id":"<secret_id>"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['auth']['client_token'])")
CREDS=$(curl -sk "http://192.168.1.32:8200/v1/secret/data/production/heezy/gitea/postgres" \
-H "X-Vault-Token: $VAULT_TOKEN" \
| python3 -c "import sys,json; d=json.load(sys.stdin)['data']['data']; print(d['POSTGRES_USER'],d['POSTGRES_PASSWORD'],d['POSTGRES_DB'])")
# Scale down gitea, restore into gitea-postgres pod, scale back up
sudo microk8s kubectl scale deployment gitea -n gitea --replicas=0
POD=$(sudo microk8s kubectl get pod -n gitea -l app=gitea-postgres -o jsonpath='{.items[0].metadata.name}')
gunzip -c /nfs/heezy/backups/gitea/gitea-postgres-<date>.sql.gz \
| sudo microk8s kubectl exec -i -n gitea "$POD" -- \
env PGPASSWORD=<password> psql -U gitea gitea
sudo microk8s kubectl scale deployment gitea -n gitea --replicas=1
Data PVC restore¶
See the general K8s restore runbook. The gitea-data PVC is in the gitea namespace, label selector app=gitea.
Remediation Backlog¶
Ordered by severity, from the 2026-08-08 audit.
| # | Gap | Action |
|---|---|---|
| 1 | Workflow has no schedule: trigger |
The top priority. These workflow files are generated by scripts/generate-workflows.py, and the generator does emit schedule: '0 14 * * 0' for this playbook — the committed file has been hand-edited and lost it. Regenerating also puts several other playbooks onto schedules they do not currently run on, so review before applying. Note the generator's cadence is weekly, not the daily this page promises: pick one and make generator, workflow, and docs agree |
| 2 | ~~The role writes no tarballs~~ | Resolved — run 667 produced real archives for 22 of 24 targets. The earlier empty state was a partially failed 2026-07-28 run, not a permanent defect |
| 3 | Workflow cannot fail | Per-target isolation means a run where 100% of targets fail still exits 0. Add a final gate that exits non-zero if no new files landed on NFS |
| 4 | No verification that backups are restorable | Nothing checks tarball size or does a test extract. A 0-byte tarball would look identical to success |
| 5 | Longhorn has no backup-target and no recurring snapshot jobs |
Register an S3 or NFS backup target and add a recurringjobs.longhorn.io snapshot schedule as a second line of defence |
| 6 | 4 stateful PVCs are not in the target list | See Uncovered by oversight |
| 7 | Restore paths are undocumented-but-assumed | The Gitea restore procedure above references /nfs/heezy/backups/gitea/gitea-postgres-<date>.sql.gz, which does not exist. No restore procedure in this document has been tested against a real artifact |
| 8 | TrueNAS (192.168.1.200) has no backup coverage | NFS failure = loss of all NFS backup copies until the next S3 sync |
| 9 | Plex config PVC is large | Monitor that the backup completes within the 60-minute workflow timeout |
| 10 | ~~Network backups silently captured nothing~~ | Resolved 2026-08-16 in ansible-heezy PR #44. Cisco and FortiGate verified against the real devices. See Network Devices |
| 11 | Ruckus AP has no backup coverage | Needs credentials in OpenBao and a reachable management interface. See Ruckus is still not backed up |
Item 10 is worth reading even though it is closed. It is the third instance on this
page of the same defect: a step that cannot report its own failure (see also items 3
and 4, and the volume-backup echo in the warning at the top). Every one of them
presented as a working backup for weeks or months.