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.
What makes this a DR bundle (vs. example 05)
- The
Repositoryconnects to the existing repository, withcreate.enabled: false. The repository must already exist; we are not initializing a new empty one. A typo in the bucket then shows up as a connect error, instead of quietly creating a second, empty repository at the wrong address. - A passive
Restoreis attached to the PVC'sdataSourceRefas a volume populator. It usessource.fromPolicy, has notarget, and setsonMissingSnapshot: Continue. The PVC therefore restores the latest snapshot before the app starts.
If you were mirroring the repository off-site
This scenario connects to the surviving repository, so the rebuilt cluster keeps writing into it.
If what survived is an off-site mirror and you want the new cluster to have its own repository back, with the mirror left intact, seed a new one instead. See Scenario 10: DR from a replicated repository.
Identity must match the old cluster
kopia finds the surviving snapshots by username@hostname:path. The defaults are username = SnapshotPolicy name and hostname = namespace, so rebuilding with the same name in the same namespace resolves the same snapshots automatically.
This bundle pins identity explicitly anyway, so recovery still works even if you rebuild into a differently-named namespace.
The KOPIA_PASSWORD must also be the original one. kopia cannot decrypt the repository with a new password.
Check retention before re-applying policies over surviving history
The SnapshotPolicy below adopts the repository's surviving snapshots. An adopted snapshot is then governed by GFS retention like any produced backup.
Under the default deletionPolicy: Delete, everything outside spec.retention is pruned from the repository immediately. Retention prunes deliberately bypass the mass-deletion breaker.
So a five-year history re-adopted under keepDaily: 14 loses the rest. Widen retention to what you actually intend to keep, or set the policy's defaultDeletionPolicy: Retain so pruning a row deletes only the Snapshot CR.
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: v1
kind: Secret
metadata:
name: postgres-repo-creds
namespace: billing
type: Opaque
stringData:
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/v1alpha1
kind: Repository
metadata:
name: postgres-primary
namespace: billing
spec:
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/v1alpha1
kind: SnapshotPolicy
metadata:
name: postgres-data
namespace: billing
spec:
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/v1alpha1
kind: Restore
metadata:
name: postgres-data-restore
namespace: billing
spec:
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: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: billing
spec:
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/v1alpha1
kind: SnapshotSchedule
metadata:
name: postgres-data-nightly
namespace: billing
spec:
policyRef:
name: postgres-data
schedule:
cron: "H 2 * * *"
jitter: 30m
runOnCreate: false
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¶
$ kubectl get repository postgres-primary -n billing
NAME PHASE AGE
postgres-primary Ready 20s
$ kubectl get pvc postgres-data -n billing
NAME STATUS VOLUME CAPACITY AGE
postgres-data Bound pvc-... 100Gi 35s
$ kubectl get restore postgres-data-restore -n billing
NAME PHASE AGE
postgres-data-restore Completed 40s
A Bound PVC and a Completed populator Restore mean the data is back. Start the app against it.
Kubernetes ≥ 1.24
The volume-populator handshake needs the AnyVolumeDataSource feature, which is GA from 1.24. The optional volume-data-source-validator surfaces a malformed dataSourceRef as an event instead of a PVC that silently never binds.
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.