Skip to content

Scenario 04 — Migrate an app across clusters or namespaces

Move a stateful app — data and all — somewhere new. Unlike disaster recovery, where the app keeps its name and namespace, a migration changes the app's coordinate: a new namespace (billingpayments), or a whole new cluster, reusing the same repository.

That coordinate change is the catch.

Moving an app vs. running it in several clusters at once

This page is a one-time move: the app runs in exactly one place at a time, before and after — the destination takes over the original identity and the source is decommissioned (below). If you instead want several clusters backing up to the same repository at the same time — active-active, or a warm DR standby — that's a different, supported shape: Share one repository across clusters uses identityDefaults.cluster so each cluster writes under its own distinct identity and never collides, which is exactly what this page's continue-the-lineage approach forbids (see the danger box below).

Why you can't just use fromPolicy in the destination

kopia stores each snapshot under username@hostname:path, and hostname defaults to the source namespace. In the destination namespace, a fromPolicy restore would compute the destination's identity and find nothing. So the one-time data carry restores by the raw source.identity (the source's username + hostname), which is exactly what identity mode is for.

The flow

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: connects a Repository to the same bucket, restores the source's latest snapshot by identity into a new PVC, then sets up a SnapshotPolicy + SnapshotSchedule to protect the app going forward.

The decision that matters: continue or fork the lineage

The new SnapshotPolicy's identity determines 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.

Continue-lineage is for a MOVE, not active-active

If you pin the destination to the original identity, decommission the source first. Two clusters writing the same username@hostname:path concurrently corrupt the snapshot timeline — kopia has no cross-cluster write coordination of its own. Pin-to-original means "the app lives here now," not "the app runs in both places."

If you actually want two (or more) clusters writing to this repository at the same time, don't reach for a shared identity — set identityDefaults.cluster on the repository instead, so each cluster gets its own distinct <namespace>.<cluster> identity and never collides. See Share one repository across clusters for that supported shape, including the safe order of operations to turn it on for a repository already in production.

# 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

Verify the carry-over

$ 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.

Finding the source's exact identity

If you're unsure what the source recorded, read it off a source Snapshot's status, or kopia snapshot list against the repo. For a PVC source it's <config-name>@<namespace>:/pvc/<pvcName> unless the source pinned a custom identity.

See also