Scenario 03 — Disaster recovery on a fresh cluster
The cluster is gone. A failed upgrade, a deleted namespace, a dead control plane. But the repository in object storage survived, which is the entire point of off-cluster backups. You stand up a new cluster, apply your GitOps repo, and the app’s data comes back as part of that apply. There is no “fresh install or recovery?” branch to pick.
This is the headline deploy-or-restore pattern, hardened for disaster recovery with two changes from a normal install.
The values you must get right
Section titled “The values you must get right”| Field | Must equal | Why |
|---|---|---|
KOPIA_PASSWORD |
the original repo password | kopia can’t decrypt otherwise. |
backend.s3.bucket / prefix |
the surviving bucket/prefix | that’s where the snapshots are. |
create.enabled |
false |
connect, don’t re-initialize. |
identity.username / hostname |
what the old cluster recorded | so fromPolicy resolves the old snapshots. |
# Scenario 03 — Disaster recovery: rebuild an app on a fresh cluster## The cluster (or the whole namespace) is gone, but the repository in object# storage survives — that's the whole point of off-cluster backups. You stand up# a new cluster and apply your GitOps repo; this bundle brings the app's DATA# back as part of that apply, with NO "is this a fresh install or a recovery?"# branching.## Two differences from a normal install make this a DR bundle:# 1. The Repository CONNECTS to the existing repo (create.enabled: false) — it# must already exist; we are not initializing a new empty one.# 2. A PASSIVE populator Restore (source.fromPolicy, target.populator: {},# onMissingSnapshot: Continue) is wired into the PVC's dataSourceRef, so# the PVC restores the latest snapshot BEFORE the app starts. On a truly# empty repo the PVC just comes up blank and is backed up going forward.## The load-bearing subtlety — IDENTITY must match the old cluster. kopia finds# the old snapshots by `username@hostname:path`. By default username = the# SnapshotPolicy name and hostname = the namespace, so re-creating the config with# the SAME name in the SAME namespace resolves the same snapshots automatically.# We pin `identity` explicitly here so the recovery is robust even if you rebuild# into a differently-named namespace.## Field shapes verified against crates/api. See also example 05 (deploy-or-# restore) and the Restores guide.---apiVersion: v1kind: Secretmetadata: name: postgres-repo-creds namespace: billingtype: OpaquestringData: AWS_ACCESS_KEY_ID: "REPLACE_ME" AWS_SECRET_ACCESS_KEY: "REPLACE_ME" # MUST be the same password the old cluster used — kopia can't decrypt otherwise. KOPIA_PASSWORD: "REPLACE_ME_with_the_ORIGINAL_repo_password"---apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: postgres-primary namespace: billingspec: backend: s3: # Point at the EXISTING bucket/prefix the old cluster wrote to. bucket: my-backups prefix: billing/postgres/ endpoint: s3.us-east-1.amazonaws.com region: us-east-1 auth: secretRef: name: postgres-repo-creds encryption: passwordSecretRef: name: postgres-repo-creds key: KOPIA_PASSWORD create: enabled: false # CONNECT to the surviving repo; do NOT initialize a new one---apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: postgres-data namespace: billingspec: repository: name: postgres-primary sources: - pvc: name: postgres-data # Pin the identity to what the OLD cluster recorded, so fromPolicy below # resolves the surviving snapshots no matter where you rebuild. identity: username: postgres-data hostname: billing retention: keepDaily: 14 keepWeekly: 6---# PASSIVE populator Restore (target.populator: {}). It does nothing on its own;# the PVC below consumes it via spec.dataSourceRef.apiVersion: kopiur.home-operations.com/v1alpha1kind: Restoremetadata: name: postgres-data-restore namespace: billingspec: source: fromPolicy: name: postgres-data offset: 0 # 0 = latest surviving snapshot # `target` is REQUIRED; populator intent is the explicit empty form. target: populator: {} policy: # Default for fromPolicy; explicit here. Empty repo ⇒ PVC comes up blank # instead of failing the deploy. onMissingSnapshot: Continue---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: postgres-data namespace: billingspec: storageClassName: fast-ssd accessModes: - ReadWriteOnce resources: requests: storage: 100Gi # The populator handshake — needs k8s >= 1.24 (AnyVolumeDataSource). The PVC is # provisioned by restoring the snapshot the passive Restore above resolves. dataSourceRef: apiGroup: kopiur.home-operations.com kind: Restore name: postgres-data-restore---# Resume nightly backups on the new cluster, so DR also re-establishes protection.apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotSchedulemetadata: name: postgres-data-nightly namespace: billingspec: policyRef: name: postgres-data schedule: cron: "H 2 * * *" jitter: 30m runOnCreate: falseWhat happens on apply
Section titled “What happens on apply”flowchart LR R[Repository<br/>connects to existing repo] --> BC[SnapshotPolicy<br/>identity pinned] BC --> RS[passive Restore<br/>fromPolicy: latest] RS -->|dataSourceRef populator| PVC[PVC postgres-data<br/>restored before app starts] BC --> SCH[SnapshotSchedule<br/>protection resumes]
On a cluster pointed at the existing repository, the PVC is provisioned by restoring the latest snapshot. On a genuinely empty repository, onMissingSnapshot: Continue lets the PVC come up blank and be backed up going forward. The same manifests work either way.
Verify the recovery
Section titled “Verify the recovery”$ kubectl get repository postgres-primary -n billingNAME PHASE AGEpostgres-primary Ready 20s
$ kubectl get pvc postgres-data -n billingNAME STATUS VOLUME CAPACITY AGEpostgres-data Bound pvc-... 100Gi 35s
$ kubectl get restore postgres-data-restore -n billingNAME PHASE AGEpostgres-data-restore Completed 40sA Bound PVC and a Completed populator Restore mean the data is back. Start the app against it.
See also
Section titled “See also”- Restores → deploy-or-restore and example 05: the populator mechanism in detail.
- Scenario 04, migrate across clusters: when the destination’s name or namespace is different, and
fromPolicywon’t resolve the old snapshots. - Repositories & backends:
create.enabledand connection details. - Scenario 10, DR from a replicated repository: when the survivor is a mirror and you seed a new repository from it with
spec.seed.