Skip to content

Scenario 04 — Migrate an app across clusters or namespaces

Move a stateful app, data and all, somewhere new. In disaster recovery the app keeps its name and namespace. A migration is different: it changes the app’s coordinate, either to a new namespace (billing becomes payments) or to a whole new cluster reusing the same repository.

That coordinate change is the catch.

flowchart LR
  subgraph src[Source — namespace billing]
    S[(snapshots<br/>postgres-data@billing)]
  end
  subgraph dst[Destination — namespace payments]
    REPO[Repository<br/>same bucket, connect] --> RST[Restore<br/>by raw identity]
    RST --> PVC[PVC postgres-data]
    BC[SnapshotPolicy<br/>identity pinned to billing] --> SCH[SnapshotSchedule]
  end
  S -. restore by identity .-> RST

Applied in the destination namespace, the bundle does three things. It connects a Repository to the same bucket. It restores the source’s latest snapshot by identity into a new PVC. Then it sets up a SnapshotPolicy and a SnapshotSchedule to protect the app going forward.

The decision that matters: continue or fork the lineage

Section titled “The decision that matters: continue or fork the lineage”

The new SnapshotPolicy’s identity decides whether the destination’s future snapshots extend the original timeline or start a fresh one:

Choice How Result
Continue the lineage pin identity to the original username/hostname (the bundle does this) new snapshots dedup against the carried-over history; one logical timeline.
Fork a fresh lineage delete the identity block; let it default to the destination namespace a clean new timeline under the new coordinate.
deploy/examples/scenarios/04-migrate-across-clusters.yaml
# Scenario 04 — Migrate an app to a new cluster or namespace
#
# Unlike DR (scenario 03, where the app's name and namespace are unchanged), a
# migration MOVES the app somewhere with a DIFFERENT coordinate — a new namespace
# (`payments`) or a new cluster — while reusing the same repository. The catch is
# kopia's identity model: a snapshot is stored under `username@hostname:path`,
# and hostname defaults to the SOURCE namespace. So in the destination you cannot
# resolve the source's snapshots by `fromPolicy` (its identity would compute the
# DESTINATION namespace). You restore by the RAW identity instead.
#
# This bundle, applied in the destination namespace, does the move:
# 1. Repository — connects to the SAME bucket the source wrote to.
# 2. Restore — by source.identity (the source's username@hostname:path) into
# a new PVC in the destination. This is the one-time data carry.
# 3. SnapshotPolicy + SnapshotSchedule — protect the app going forward.
#
# IDENTITY CHOICE for the new SnapshotPolicy (the important decision):
# * To CONTINUE the original snapshot lineage (keep dedup history, one logical
# timeline), pin `identity` to the ORIGINAL username/hostname — done below.
# * To start a FRESH lineage under the new coordinate, delete the `identity`
# block and let it default to the destination namespace.
#
# WARNING: pin-to-original is for a MOVE — decommission the source first. Two
# clusters writing the SAME identity concurrently corrupts the snapshot timeline.
#
# Field shapes verified against crates/api. source.identity REQUIRES an explicit
# spec.repository (there's no Backup/SnapshotPolicy to infer it from).
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-repo-creds
namespace: payments
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "REPLACE_ME_with_the_ORIGINAL_repo_password"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: postgres-primary
namespace: payments
spec:
backend:
s3:
bucket: my-backups
prefix: billing/postgres/ # the SAME prefix the source app used
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 existing repo; do not initialize
---
# One-time data carry: restore the source app's latest snapshot by its RAW
# identity (username/hostname as recorded by the SOURCE in namespace `billing`).
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
name: postgres-migrate-in
namespace: payments
spec:
repository:
kind: Repository
name: postgres-primary
source:
identity:
username: postgres-data # the SOURCE SnapshotPolicy name
hostname: billing # the SOURCE namespace (NOT payments)
sourcePath: /pvc/postgres-data # PVC sources record /pvc/<pvcName>
# snapshotID: k1f1ec0a8 # pin an exact snapshot, or omit for latest
target:
pvc:
name: postgres-data
storageClassName: fast-ssd
capacity: 100Gi
accessModes:
- ReadWriteOnce
policy:
onMissingSnapshot: Fail # the snapshot must be there — this is the migration
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: postgres-data
namespace: payments
spec:
repository:
name: postgres-primary
sources:
- pvc:
name: postgres-data
# CONTINUE the original lineage: keep writing under billing/postgres-data so the
# new cluster's snapshots dedup against the carried-over history. Remove this
# block to fork a fresh timeline under payments/postgres-data instead.
identity:
username: postgres-data
hostname: billing
retention:
keepDaily: 14
keepWeekly: 6
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
name: postgres-data-nightly
namespace: payments
spec:
policyRef:
name: postgres-data
schedule:
cron: "H 2 * * *"
jitter: 30m
runOnCreate: false
Terminal window
$ kubectl get restore postgres-migrate-in -n payments -w
NAME PHASE AGE
postgres-migrate-in Resolving 3s
postgres-migrate-in Restoring 11s
postgres-migrate-in Completed 38s
$ kubectl get pvc postgres-data -n payments
NAME STATUS VOLUME CAPACITY AGE
postgres-data Bound pvc-... 100Gi 40s

Start the app in payments against the restored PVC, confirm it, then tear down the source. The first scheduled backup in payments will dedup against the existing data rather than re-uploading it.