Skip to content

Backup strategy

This document captures what gets backed up, where it goes, how often, and how recovery actually works. It records the architecture decisions and the test plan for verifying restores work — because untested backups are documentation, not insurance.

Why this matters

The current state is dangerous: backup2 exists as an Incus VM on kyojin with USB JBOD passthrough (~30 TB raw across 4 drives) but there is no documented backup pipeline using it. A site-level event (fire, theft, multi-disk failure, malicious tenant, accidental tofu destroy) loses everything. The prototype is past the point where this is acceptable for a production-replacement.

Three categories of risk this is meant to mitigate:

RiskFailure modeRecovery requirement
Hardware failure (single disk, single host)A drive dies, a host bricks (see ibuypwr)Restore individual workloads to surviving infra in hours
Operator error (tofu destroy, accidental delete, bad migration)Recent loss, otherwise infra healthyRestore specific items (pod data, repo, config) in minutes
Site-level event (fire, theft, ransomware on backup2 itself)All on-site copies inaccessibleRestore from off-site copy in days, with degraded service acceptable

Each requires a different copy. 3-2-1 rule: at least 3 copies, on 2 different media, with 1 off-site.

What gets backed up

Categorize by what it is, where it lives, and what software produces it:

DataLocation nowBackup targetWhy it matters
OpenTofu stateopentofu/terraform.tfstate (committed to git)Git remote + backup2 + off-siteSource of truth for Incus resources; loss = re-import nightmare
SOPS-encrypted secretsk0s/platform/*-values.yaml (committed to git)Git remote + backup2 + off-siteRe-deriving credentials is painful, sometimes impossible
Age private keyoperator-side, outside the repoEncrypted off-site (e.g. password manager + cold storage)If lost, every encrypted secret is unrecoverable
Ansible inventory + rolesgit-tracked, pushed to ForgejoGit remote + backup2 + off-siteSame as Tofu state
k8s etcdInside k0s controllersbackup2 (snapshot via k0s etcd snapshot) + off-siteCluster state; without it, recreating the cluster from scratch
OpenBao unseal/recovery keysoperator-heldOff-site only — multiple custodiansSealed Bao is bricked without these
Forgejo repositoriesPVC inside k0sbackup2 (rsync/restic from Forgejo data dir) + off-siteSource code; the repo this infra lives in. Backing up Forgejo to itself is circular.
Zot OCI imagesPVC inside k0sbackup2 (restic of registry storage)Recoverable by re-pulling from upstream, but slow and creates external dependencies
Prometheus TSDB50Gi PVC, 90d retentionOptional — large, recoverable from re-scrapeHistorical metrics; nice to have, not critical
Loki log chunks20Gi PVCOptional — same as PrometheusHistorical logs; same trade-off
Grafana dashboardsInside Grafana DB (sqlite/PVC)backup2 (sqlite dump) + git (export-as-json on commit)Cheap to back up, painful to recreate
Application data (Actual Budget, OWASP Juice Shop, etc.)PVCsbackup2 + selective off-sitePer-app prioritization
VDI persistent statePVCs in vdi namespacebackup2 only (per-user, large, low-priority)User home dirs in webtops
IncusOS pool recovery keysoperator-side, outside the repooff-site backup of operator workstationWithout them, factory-reset = data loss on user pools
Ansible SSH keyoperator-side, gitignoredOff-site (encrypted)Lost = regenerate + re-key every host

Software choices

Three layers, each with its own tool

LayerToolUse
Block-level instance snapshotsincus snapshot (ZFS-backed, instant)Per-VM point-in-time, lives on the same host’s pool. First line of defense — quick rollback after a bad migration.
Block-level cross-host replicationZFS send/receive (via incus copy --refresh or zrepl)Replicate snapshots from kyojin/theia to backup2. Block-efficient (delta-only). Same pattern as warm-migration but with snapshots as the unit.
File-level dedup’d encrypted backupResticVolume contents → backup2 + off-site. Encrypted, deduplicated, supports many backends (local, S3, B2, SFTP). Granular file-level restore.

This layered approach gives:

  • Fast rollback via Incus snapshots (seconds, no data movement)
  • Host loss tolerance via ZFS replication to backup2 (minutes-to-hours, depends on data volume)
  • Site loss tolerance via Restic to off-site (hours-to-days, fully encrypted at rest)

Why Restic specifically

ToolEncryptionDedupBackendsVerdict
ResticYes (AES-256)Yes (chunk-level)Local, SFTP, S3, B2, Azure, GCS, RESTPick. Modern, well-maintained, scriptable, content-addressed
BorgYesYesLocal, SFTPComparable; ecosystem smaller, no native S3
BupYesYes (git-pack format)LocalMature but quieter project
DuplicityYesNo (incremental tarballs)ManyOld-school; less efficient
tar + gpgManualNoAnywhereReliable but high-effort to manage retention

Restic’s S3/B2 support makes off-site cheap (Backblaze B2 is ~$0.005/GB/month).

Where backups live

On-site: backup2 VM with USB JBOD

backup2 is an Incus VM on kyojin with PCI passthrough of the ASMedia ASM2142 USB controller (PCI 0000:17:00.0). It owns the 4-drive JBOD enclosure (~30 TB raw, configured as ZFS — exact topology TBD).

  • Restic repos stored on the JBOD pool — one repo per data category (e.g. forgejo, zot, etcd, vm-snapshots)
  • ZFS receive targets — replicated *_instances snapshots from kyojin and theia
  • Quarterly/yearly archives on slower or cold-storage media if needed

backup2 itself needs to be backed up via off-site (otherwise it’s the SPOF for the on-site copy).

Off-site: cloud object storage

Backblaze B2, Wasabi, or AWS S3 (Glacier Deep Archive for cheap cold storage):

  • Restic repo replicated from backup2 (via restic copy)
  • Critical secrets (Age key, OpenBao recovery keys) — separately encrypted, multiple custodians, manual procedure (not part of the automated pipeline)
  • Quarterly snapshots of the entire backup2 Restic repo as cold-storage anchors

Off-site: git remote (for code/config)

Already in place via Forgejo + GitHub mirror (if configured). OpenTofu state, SOPS-encrypted values, Ansible inventory are all in git — pushing to a remote outside our infra IS off-site backup for them.

Mirror to a second git remote (GitHub, GitLab, another Forgejo) so a Forgejo outage doesn’t lose access. The repo’s primary identity stays local Forgejo; the mirror is read-only safety.

Schedule and retention

Backup typeFrequencyRetentionWhere
Incus instance snapshotsDaily7 daily, 4 weeklySame host
ZFS send to backup2Daily30 daily, 12 weekly, 12 monthlybackup2 JBOD
Restic to backup2 (k8s PVC dirs)Hourly for hot data, daily for coldRestic policy: 24 hourly, 14 daily, 8 weekly, 12 monthlybackup2 JBOD
Restic copy to off-siteDaily90 days hot tier, then 365 days coldB2/S3
Age key / recovery keysOn rotationIndefiniteSecure off-site (multiple custodians)
Git push to mirrorOn every commitIndefiniteSecond git host

Restic’s forget --keep-* policy handles automatic pruning.

Orchestration

Where the backup runs from:

  • Cron jobs on each Incus host for instance-level work (snapshots, ZFS send to backup2)
  • CronJobs in k0s for k8s-internal work (etcd snapshot, Restic of PVC contents — using a sidecar pattern or dedicated backup pod)
  • Cron on backup2 for the off-site copy (Restic to B2/S3)
  • Ansible playbook for the initial setup of all the above; idempotent so re-running fixes drift

Implementation: an ansible/playbooks/backup/ directory with playbooks per concern. The Restic repo init step is one-time, manual (need to securely handle the Restic password); afterwards everything is automated.

Restore testing

Untested backups are documentation, not insurance. Schedule restore drills:

DrillFrequencyWhat you verify
Full Forgejo restore to a scratch namespaceMonthlyk8s PVC restore + service start works end-to-end
Single-VM restore from instance snapshotMonthlyIncus snapshot/copy mechanics work
etcd restore to a fresh k0s clusterQuarterlyk8s state can be recovered after total cluster loss
Off-site restore (B2 → backup2 → target)QuarterlyBandwidth and credentials are working; no surprises
Bare-metal recovery: kyojin loses all disks → reinstall + restoreAnnuallyFull DR runbook works; secret recovery flow works

Document each drill’s outcome (in a sibling doc, e.g. docs/backup-drill-log.md). The drills aren’t optional — they catch silent backup corruption, expired credentials, missing dependencies. A backup you’ve never restored is Schrödinger’s data.

Phased plan

Phase 1: in-cluster backup (get something working)

  1. Stand up Restic repo on backup2’s JBOD (Ansible playbook to init the pool, install Restic, generate password)
  2. Add CronJobs in k0s for: etcd snapshot → write to PVC → Restic to backup2; Forgejo data dir → Restic to backup2
  3. Daily Restic of OpenBao data
  4. Test single-PVC restore to a scratch pod

Outcome: covers the most painful “operator error / single-PVC corruption” recovery scenarios.

Phase 2: instance-level backup

  1. Cron on kyojin/theia: nightly incus snapshot create + zfs send of the instance pools to backup2
  2. Cron on backup2: receive, retain per policy
  3. Document the per-instance restore procedure (incus copy backup2:<snap> kyojin:<new-name>)

Outcome: covers “host loss” — losing kyojin or theia while preserving instance state.

Phase 3: off-site

  1. Provision B2/S3 bucket with credentials in OpenBao
  2. Cron on backup2: nightly restic copy from local repo to remote
  3. Bandwidth test; tune schedule and dedupe targets if needed
  4. First restore drill from off-site

Outcome: covers site loss.

Phase 4: ongoing drills

Schedule the drill calendar; build the runbooks; train a second operator on them.

Decision log

  • Restic over Borg. Cloud backend support (B2/S3) is the deciding factor. Borg ecosystem is solid but lacks native cloud targets — borg + rclone works but adds another moving part.
  • ZFS send/receive for block-level, Restic for file-level — both, not either. Different recovery scenarios benefit from different tools. ZFS is faster for full-VM restores; Restic is granular for “I need that one file from last Tuesday.”
  • backup2 on-site as the primary aggregation point. Single backup target simplifies orchestration (everything pushes to one place). Trade-off: backup2 is a SPOF for on-site backups; mitigated by off-site copy.
  • Off-site to commercial S3-compatible (B2/Wasabi), not another DC. RRCHNM presumably has a second site option (HPC?) but cloud is cheaper and faster to stand up. Revisit if compliance forbids cloud or if egress costs become significant.
  • No Velero (k8s-native backup tool). Velero is heavier (operator + CRDs) and overlaps with what Restic already provides. Worth revisiting if we adopt CSI volume snapshots and want their cluster-aware orchestration.

Re-evaluation triggers

  • A backup fails or a restore drill fails → emergency redesign of the failing piece
  • Storage cost on backup2 or B2 grows beyond budget → reconsider retention, dedup, or move cold tier to truly-cold storage (Glacier Deep Archive, tape)
  • Compliance regime requires audit logs, immutability, multi-tenant isolation → Restic’s append-only mode + S3 Object Lock; or switch to a tool with stronger guarantees
  • Recovery time objective (RTO) becomes formal and measurable → may need warm replicas (continuous replication) instead of nightly snapshots
  • A critical secret loss event happens → retrospective on Age key and OpenBao recovery key handling, custodian model, secure storage process