Backups¶
Re-verified against NFS, the role defaults and the cluster on 2026-08-22.
One tool, two cadences. Restructured 2026-08-19
Everything is Ansible now. There are no backup CronJobs in heezy-k8s, no cron on
big-boi, and no calendar gates buried in shell. Every capture and every upload is a
task in a role under ansible-heezy, triggered by a generated Gitea Actions
workflow. The only mechanism-specific case left is the OpenBao raft snapshot, which
has to be an API call because a tar of a live raft store is not a restorable
snapshot.
Two plays, deliberately split. Capture cadence and shipping cadence answer different questions, and fusing them meant the only lever for uploading less was capturing less:
heezy-backup.ymlcaptures everything to NFS. Daily, 02:00 UTC.heezy-backup-offsite.ymlships one generation to S3. Weekly, Saturday 08:00 UTC.
Plex is no longer archived whole. The old target tarred all of /config, 11.9
GiB, of which Media, Metadata and Cache are regenerable artwork and transcode
scratch. It was 60% of every upload. The target is now the three paths that carry
state a restore cannot recreate, ~1.6 GiB, which is what the retired
plex-config-backup CronJob had been capturing separately all along.
One restore is 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.
Design¶
| Tier | Schedule | Destination | Retention | Enforced by |
|---|---|---|---|---|
| Local | Daily 02:00 UTC | big-boi:/nfs/heezy/backups/ |
7 days rolling | heezy_backup_retention_days, heezy-backup role |
| Off site | Saturday 08:00 UTC | s3://heezy-backups-025066240222/heezy/ |
30 days, STANDARD_IA | S3 lifecycle, terraform-heezy/environments/production/aws/backup.tf |
Both run as generated Gitea Actions workflows on the act-runner. Each target is an independent task, so one failure does not stop the others, and each play ends with a gate that fails the run if any target failed. Discord is notified per failure and on final summary.
Why Saturday 08:00 UTC¶
The ask was 3am to 5am Eastern. Cron in Gitea Actions has no timezone, so the slot has to hold in both halves of the year: 08:00 UTC is 03:00 EST in winter and 04:00 EDT in summer, inside the window either way.
Why one generation and not the whole tree¶
NFS holds 7 daily snapshots of every target. aws s3 sync over the tree pushes all 7 on
each run. A weekly off-site RPO needs 1, so the ship script groups files by series and
uploads only the newest member of each. A series is the directory plus the filename with
its date stamp removed; keying on the directory alone would be wrong for network/,
which holds cisco-core, fortigate and ruckus side by side.
The retention constraint¶
Local retention must stay at or above the shipping interval. At 7 days local and a 7
day interval, every snapshot is still on disk when the weekly run comes for it. Lengthen
the interval without lengthening local retention and snapshots are pruned before they are
ever shipped. This is not hypothetical: the S3 step used to be gated on
[ "$(date +%d)" = "01" ], the daily schedule did not exist, and nothing reached S3 for
67 days.
Why STANDARD_IA and not GLACIER¶
GLACIER Flexible Retrieval bills a 90 day minimum per object. Expiring at 30 days does not stop that clock, so a 30 day GLACIER tree is billed as a 90 day one: roughly 3x the bytes actually kept, which lands within pennies of STANDARD_IA at this size. STANDARD_IA's minimum is exactly 30 days, so the expiration sits on the boundary and pays no early deletion fee. Same money, and restores go from 3-5 hours to immediate. That matters most for the OpenBao raft snapshot, which every other restore depends on.
Retired¶
| What | Replaced by | When |
|---|---|---|
plex-config-backup CronJob (heezy-k8s, 04:00) |
the plex target in heezy_backup_k8s, same three paths |
2026-08-19 |
uptime-kuma-backup CronJob (heezy-k8s, 03:00) |
the uptime-kuma target, which already tarred /app/data including kuma.db |
2026-08-19 |
Daily aws s3 sync inside heezy-backup |
heezy-backup-offsite.yml, weekly |
2026-08-19 |
Full /config Plex tar, 11.9 GiB |
three-path Plex tar, ~1.6 GiB | 2026-08-19 |
nzbhydra2 was decommissioned 2026-08-08 and removed from the role; its directory is a
leftover. soularr was never in the target list and never should be, see
Uncovered by oversight — now closed.
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: one,snapshot-cleanup(Sundays 05:00). It deletes system-generated snapshots and purges the frozen layers left behind by replica rebuilds. It takes no snapshotsbackups.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 | Preferences.xml, Plug-in Support/Databases, Plug-in Support/Metadata Combination under /config/Library/Application Support/Plex Media Server |
Not full /config. Media, Metadata and Cache are regenerable and were 11.9 GiB of the 12 GiB archive |
| 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 | postgres.heezy.local: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. Verified: gitea/gitea-postgres-20260822_020024.sql.gz, 5.6 MB |
The n8n database no longer exists. n8n was retired in August 2026 and its database was dropped; the
container on big-boi kept the name n8n-postgres but now serves heezy, maintenance and
projects. There is no n8n dump target in the role and /nfs/heezy/backups/n8n/ is a leftover
directory.
Big-boi Docker Compose (SSH tar)¶
heezy_backup_compose, heezy_backup_remote_configs and heezy_backup_app_data are all empty
lists. The only big-boi target is a dedicated task:
| Service | Source Path | Latest archive |
|---|---|---|
| open-webui | /opt/big-boi/open-webui/ (excludes cache) |
8.6 KB, 2026-08-22 |
8.6 KB is small enough to be worth a look. Either the chat history lives somewhere the tar does not reach, or the exclude is too broad.
Game servers¶
Handled by the game-backup role, dispatched or scheduled by
playbook-game-backup-execution.yml at 01:00 UTC daily. Six archives, one per world.
| Archive | Volume | Quiesced |
|---|---|---|
games/cs16/ |
cs16-data |
No |
games/minecraft/bedrock/ |
bedrock-data, both worlds in one archive |
No |
games/ut2k4/ |
ut2k4-data |
No |
games/minecraft/java/ |
minecraft-java-data |
Yes |
games/minecraft/adventure/ |
minecraft-adventure-data |
Yes |
games/minecraft/cobblemon/ |
minecraft-cobblemon-data |
Yes |
It reads each volume out through the container that holds it, not by mounting the volume. The
volumes are ReadWriteOnce, so a backup job cannot mount one while the server has it. Every game
container carries a read-only mount of its whole volume at /backup-src for this, which is also
what makes the archive match the volume root, the shape the restore extracts.
It runs on a cluster node rather than the CI runner. The runner has neither cluster access nor
/nfs, and a game backup written somewhere that merely looks like the destination reports success
and is thrown away with the job container.
Quiesced worlds get save-off and save-all flush before the read and save-on after, whether the
read worked or not. Only the Java images ship rcon-cli.
Three checks, and the second is the one that matters:
- an absolute floor of 1 MB, because an archive of a few hundred bytes is
tarwriting an error into a file and exiting happy - a relative floor against the previous archive. Half of last night's size is not a smaller world, it is a truncated read, and that is the failure that looks most like success
- that the share is actually mounted, before a byte is written
A failing archive is deleted rather than kept and counted. Failures are collected across all six and raised at the end, so one broken game does not hide the state of the other five. Retention is seven archives per world.
Other¶
| What | Method |
|---|---|
| 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. 2026-08-22 capture: 5.5 KB |
| FortiGate (192.168.1.1:8443) | FGT60E, FortiOS 7.4.3 | REST API session cookie, monitor/system/config/backup?scope=global |
Working. 2026-08-22 capture: 427 KB |
| Ruckus AP (192.168.2.66) | Unleashed, master AP | Unverified session cookie | Not working — config export only. SNMP metrics do work |
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 |
| Management interface, partly | The AP (ec:8c:a2:27:3b:f0, DHCP-reserved at 192.168.2.66 on VLAN 200) does answer SNMP on 161 — Prometheus has polled ruckus-ap.heezy.local as an if_mib target since 2026-08-20 and the target is up. It still answers neither ICMP nor 443/80/8443 from the backup host, so HTTP config export remains blocked. 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, and check whether the web UI answers from a host on
VLAN 200. The lgtm role treats it as an Unleashed master (only the master runs an SNMP agent;
ruckus-ap-member at 192.168.2.45 does not answer 161 and is deliberately not polled), so the
_login / _system/backup calls in the template need rewriting for Unleashed rather than
ZoneDirector.
Both APs have had heezy.local forward records since 2026-08-20.
NFS Layout¶
/nfs/heezy/backups/
k8s/
<service>/ # one tar.gz per run, 25 service dirs
gitea/
gitea-postgres-<date>.sql.gz
postgres/
heezy-<date>.sql.gz
games/
cs16/
ut2k4/
minecraft/
bedrock/
java/
adventure/
cobblemon/
network/
cisco-core-<date>.txt
fortigate-<date>.conf
open-webui/
Six directories on NFS are no longer written to by any target and can be deleted: n8n/,
openclaw/, lgtm/, app-data/, docker-compose/, and the old top level minecraft/, which the
game backup replaced with games/minecraft/. Inside k8s/, nzbhydra2/ and soularr/ are empty
leftovers.
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 — now closed¶
The three gaps identified on 2026-08-08 were added to the target list and are capturing. Verified on NFS 2026-08-22:
| PVC | Namespace | Latest archive |
|---|---|---|
heezy-projects-data |
heezy | k8s/heezy-projects/…-20260822_021210.tar.gz, 1.4 MB |
heezy-maintenance-config |
heezy | k8s/heezy-maintenance/…-20260822_021211.tar.gz, 12.5 MB |
swag-plex-config |
heezy | k8s/swag-plex/…-20260822_021214.tar.gz, 697 KB |
soularr-config is deliberately excluded, not an oversight. soularr mounts a ConfigMap of the
same name at /data/config.ini plus the nfs-slskd-downloads PVC. Nothing mounts the 5Gi Longhorn
PVC, which is why it sits detached. Delete the PVC rather than back it up.
Two targets produced nothing in the 2026-08-22 capture
Every other target has an archive stamped 20260822_02xxxx. tailscale and heezy-finance stop
at 20260820_125938 — two days old. Both pods are Running and both match their label selectors
(app=tailscale, app=heezy-finance), so the selector is not the problem.
This is the same shape as the network-backup defect below: the run reported success while two targets silently produced nothing. The per-target failure gate catches a task that errors; it does not catch one that finds no pod and exits 0.
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 loses every local copy, leaving only the last weekly S3 generation. Worst-case exposure is now 7 days rather than 1 — the cost of moving the ship to weekly, and the reason local retention must not drop below the ship interval |
Manual Trigger¶
Gitea UI: heezy-admin/ansible-heezy → Actions → pick the workflow → Run workflow.
| Want | Workflow |
|---|---|
| Capture everything to NFS now | playbook-heezy-backup-execution.yml |
| Ship the newest generation to S3 now | playbook-heezy-backup-offsite-execution.yml |
The off-site play reads NFS and never captures, so running it alone ships whatever the last daily capture left behind. To force a fresh off-site copy, run the capture first, then the ship.
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://gitea.heezy.local:3000/api/v1/repos/heezy-admin/ansible-heezy/actions/workflows/playbook-heezy-backup-offsite-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"
# One line per series on NFS, newest first. This is what the off-site run ships.
ssh mcp-admin@192.168.1.21 "cd /nfs/heezy/backups && sudo find . -mindepth 2 -type f -printf '%p\n' | sed -E 's/-[0-9]{8}_[0-9]{6}//' | sort | uniq -c"
# S3, newest first. Expect ~33 objects per weekly generation, four generations deep.
aws s3 ls s3://heezy-backups-025066240222/heezy/ --recursive --human-readable | sort | tail -40
Restoring Gitea¶
Postgres restore¶
# Get creds from OpenBao
VAULT_TOKEN=$(curl -sk -X POST "http://openbao.heezy.local: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://openbao.heezy.local: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~~ |
Resolved. SCHEDULES in scripts/generate-workflows.py now carries heezy-backup at 0 2 * * * and heezy-backup-offsite at 0 8 * * 6. Both workflow files are generated; edit the dict, never the YAML |
| 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~~ | Resolved. tasks/main.yml ends with a fail: gated on _failed_steps \| length > 0, so a run with any errored target exits non-zero. It does not catch a target that finds no pod and exits 0 — see the tailscale/heezy-finance note above |
| 4 | Partially resolved: size floor, no test extract | Each volume target now enforces MIN_BYTES (default 200, min_bytes: 0 for heezy-finance), so a 0-byte tarball is rejected. Nothing does a test extract, so a truncated-but-nonzero archive still looks like success |
| 5 | Longhorn has no backup-target |
Still empty as of 2026-08-22. There is now one recurring job, snapshot-cleanup (Sundays 05:00), but it deletes snapshots rather than taking them. Register an S3 or NFS backup target and add a snapshot schedule as a second line of defence |
| 6 | ~~4 stateful PVCs are not in the target list~~ | Resolved. heezy-projects, heezy-maintenance and swag-plex are captured and verified on NFS 2026-08-22. soularr-config is deliberately excluded as an orphaned PVC. See Uncovered by oversight — now closed |
| 7 | Restore paths are documented but untested | /nfs/heezy/backups/gitea/gitea-postgres-<date>.sql.gz does exist now — 5.6 MB, dated 2026-08-22. The artifact the procedure needs is there; the procedure itself has still never been run. OpenBao remains the only restore proven against real data |
| 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~~ | Resolved 2026-08-19. The target is now three paths instead of all of /config: 11.9 GiB to ~1.6 GiB. Media, Metadata and Cache are regenerable and were never worth archiving |
| 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 config backup | Needs credentials in OpenBao and an Unleashed-shaped export call. SNMP metrics now work, so the device is reachable; the HTTP management interface is not. See Ruckus is still not backed up |
| 12 | Two targets silently captured nothing on 2026-08-22 | tailscale and heezy-finance last produced an archive on 2026-08-20 while every other target ran. The run reported success. Same class of defect as items 3, 4 and 10 |
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.