Snapshot replication
A SnapshotReplication copies selected snapshots, meaning the manifests plus the content they reference, from one repository into another on a schedule. It wraps kopia snapshot migrate.
Repository replication mirrors a repository’s raw blobs to a passive destination backend. Snapshot replication is different: its source and destination are both real repository CRs, either a Repository or a ClusterRepository. Each has its own password and its own format, and each keeps its own life. The destination can keep taking direct backups of its own while also receiving copies.
How it works
Section titled “How it works”- It is namespaced, and lives alongside its source, like
MaintenanceandRepositoryReplicationdo.sourceRefanddestinationRefeach name aRepositoryor aClusterRepository. AClusterRepositorydestination is the main consolidation setup. Replicating a repository into itself is rejected at admission. - On each cron slot the controller launches a mover Job named
<name>-srepl-<unix>. It uses the same scheduling machineryMaintenanceuses: croner, deterministic jitter, and one run at a time. The mover connects to the source read-only, connects to the destination normally, and runskopia snapshot migrate. - Identity is preserved. A copied snapshot keeps its
username@hostname:path, its start and end times, and its description. At the destination it looks exactly like the snapshot it is a copy of. - Runs are idempotent and incremental. kopia keys migration on
(identity, startTime). A snapshot already present at the destination is skipped, and counted instatus.lastRun.alreadyPresent. Unchanged content is deduplicated against what the destination already stores. Re-running is always safe. - Every run is verified afterwards.
kopia snapshot migrateexits 0 even when individual sources failed to migrate. So the mover re-lists the destination itself, and fails the run loudly if any selected snapshot did not arrive. A greenSucceededphase means the copies are really there. - Each copy becomes a
SnapshotCR withorigin: replicated, in the replication’s namespace, pinned to the destination repository throughspec.repository, withdeletionPolicy: Delete. The copies are therefore first-class: visible tokubectl get snapshots, restorable, and deletable through the CR like any other snapshot. The destination’s catalog scan recognizes replicated rows and does not duplicate them asdiscovered. - The copy CRs carry no ownerReference back to the
SnapshotReplication, so deleting the replication CR never deletes the copies. Onlyspec.pruning, or deleting the copySnapshotCRs yourself, removes replicated data.
Try it / minimal manifest
Section titled “Try it / minimal manifest”The apply-ready example is deploy/examples/39-snapshot-replication.yaml. It holds a destination Repository on off-site S3, with its own password Secret, plus the replication CR:
apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotReplicationmetadata: name: nas-primary-to-offsite namespace: billingspec: # Copy FROM this repository (must already exist — see example 01). Opened # read-only; a replication never writes to its source. sourceRef: kind: Repository name: nas-primary # Copy INTO this repository (the Repository above). Must be a different # repository — replicating a repository into itself is rejected at admission. destinationRef: kind: Repository name: offsite-s3 schedule: cron: "0 6 * * *" # nightly, after the 02:xx backups have landed jitter: 30m # Which snapshots to copy. Omit `selection` entirely to copy EVERY identity's # full history (`kopia snapshot migrate --all`). selection: identities: # Matchers glob per identity component (`*` any run, `?` one character, # anchored to the whole component). Every SET component must match; an # unset component matches anything. Exclude wins over include. include: - username: "pg-*" # all the postgres policies… exclude: - sourcePath: "/scratch/*" # …but never scratch paths # true = only each identity's most recent snapshot (a cheap seed); # false/omitted = full history. latestOnly: false migrate: # Snapshots migrated concurrently (kopia default: 1, sequential). The main # knob for large first runs. parallel: 4 # Whether kopia POLICIES ride along: none (default — a Kopiur-managed # destination keeps retention CR-driven), copy, or copyOverwrite. policies: none # Bandwidth/ops caps for THIS replication's runs, one block per side. # `kopia snapshot migrate` has no speed flags, so each side is applied as # `kopia repository throttle set` on that side's connection before the # migrate runs. Each side overrides THAT side's repository's # `moverDefaults.throttle` field by field: a knob set here wins, a knob left # unset keeps the repository's value. Every set knob must be >= 1. # # Caveat worth knowing before you tune these down: byte caps only bite on # COLD backend traffic (cached content bypasses the limiter), and on # small-object workloads the real throughput lands far below the nominal # rate. Treat them as a ceiling for large transfers and measure. throttle: # Reading out of nas-primary — on the LAN, so leave it roomy. source: downloadBytesPerSecond: 209715200 # 200 MiB/s # Writing to the off-site bucket — the scarce link, and the one worth # capping. Unset knobs (downloadBytesPerSecond, readOpsPerSecond) fall # back to the destination Repository's own moverDefaults.throttle. destination: uploadBytesPerSecond: 10485760 # 10 MiB/s writeOpsPerSecond: 100 # What happens to already-replicated copies on later runs. Exactly one of: # none: never prune (the default when `pruning` is omitted) # mirrorSource: delete a copy when its snapshot vanished from the source # (a bulk source-side vanish is HELD by the destination # repository's deletion breaker — ransomware at the source # cannot empty the off-site copy in one wave) # retention: independent GFS retention at the destination, regardless of # what the source still holds # Whatever the mode, copies SURVIVE deletion of this CR. pruning: retention: keepDaily: 14 keepWeekly: 8 keepMonthly: 6 # Pause replication without deleting the CR. suspend: falseWatch it:
$ kubectl -n billing get snapshotreplicationsNAME SOURCE DESTINATION SCHEDULE PHASE LAST AGEnas-primary-to-offsite nas-primary offsite-s3 0 6 * * * Succeeded 2m 1d
$ kubectl -n billing get snapshots -l kopiur.home-operations.com/origin=replicatedstatus.lastRun carries the per-run counters: identitiesSelected, snapshotsCopied, alreadyPresent, failed, pruned, and incompleteSkipped.
kubectl kopiur status and kubectl kopiur doctor render them too. kubectl kopiur snapshots list shows where a replicated row was copied from, in status.copiedFrom.
The fields you’ll change
Section titled “The fields you’ll change”| Field | What it does |
|---|---|
sourceRef / destinationRef |
The two repositories. kind defaults to Repository. Both must exist, must differ, and must be Ready, and the destination must be writable. |
schedule.cron / jitter |
When replication runs. Jenkins-style H is supported. Run it after your backup window, so each night’s snapshots are there to copy. An absent jitter inherits the source repository’s scheduleDefaults.jitter, just as timezone already did. Both are capped at 24h at admission. |
selection.identities.include / exclude |
Which kopia identities to copy. See Selecting what to copy. Omit selection entirely to copy every identity’s full history. |
selection.latestOnly |
true copies only each identity’s most recent snapshot, which makes a cheap seed. The default, false, copies the full history. |
migrate.parallel |
How many snapshots are migrated at once. kopia’s default is 1, meaning one after another. This is the main knob for large first runs. |
migrate.policies |
Whether kopia policies are copied along with the snapshots. none is the default, which keeps retention on a Kopiur-managed destination driven by CRs. The other values are copy and copyOverwrite. |
migrate.throttle.source / .destination |
Bandwidth and operation caps for this replication’s runs, one block per side. See Throttling a replication. Each one overrides that side’s repository moverDefaults.throttle, field by field. |
pruning |
What happens to already-made copies on later runs. It is exactly one of none, mirrorSource, or retention. See Pruning. |
mover |
Per-run mover overrides: resources, scheduling, and security context. It inherits the source repository’s moverDefaults. inheritSecurityContextFrom is rejected here, as it is for RepositoryReplication, because there is no workload to inherit from. |
credentialProjection |
Opt in to credential projection for a ClusterRepository source/destination whose Secret lives elsewhere. |
suspend |
Pause replication without deleting the CR. |
Run it now
Section titled “Run it now”A SnapshotReplication normally fires on its cron. But you can ask for a copy pass right now: after seeding a new destination, after fixing a failed run, or just to watch the first migrate work.
$ kubectl kopiur replication run nas-primary-to-offsite -n billing --waitsnapshotreplication.kopiur.home-operations.com/nas-primary-to-offsite run requested (2026-06-11T12:00:00Z)SnapshotReplication nas-primary-to-offsite run completed at 2026-06-11T12:09:51ZIf a RepositoryReplication and a SnapshotReplication share a name in one namespace, add --kind snapshot. Otherwise the kind is detected for you.
The plugin just stamps the kopiur.home-operations.com/run-requested annotation with an RFC3339 timestamp, so plain kubectl works too:
$ kubectl annotate snapshotreplication nas-primary-to-offsite -n billing \ kopiur.home-operations.com/run-requested="$(date -u +%Y-%m-%dT%H:%M:%SZ)" --overwriteThe timestamp pins which request the status answers. Re-applying the same value does nothing, which makes it safe under GitOps. A new timestamp starts a new run. Progress lands in status.manualRun, under requestedAt, phase, and completedAt.
The requested run takes the same path as a scheduled one: the same mover, the same gates on both repositories being Ready and the destination being writable, the same IdentityOverlap guard, and the same rule that only one run happens at a time.
Selecting what to copy
Section titled “Selecting what to copy”selection.identities takes include and exclude lists of matchers.
Each matcher can set any of the three identity components: username, hostname, and sourcePath. Every component you set must match for the matcher to match, and an unset component matches anything. At least one component must be set per matcher, and the webhook enforces that.
Components are matched with anchored globs. * matches any run of characters, including none, and ? matches exactly one. The match is against the whole component, so pg-* matches pg-main, while a plain pg does not match pg-main.
A snapshot is copied when it matches any include and no exclude. An empty or absent include means “everything”. Exclude always wins.
selection: identities: include: - username: "pg-*" # every postgres policy… - hostname: "media" # …plus everything from the media namespace exclude: - sourcePath: "/scratch/*" # …but never scratch paths latestOnly: falseMatching zero identities is a success that does nothing, reported as NoIdentitiesMatched. It is not an error: a fresh source simply has nothing to copy yet. Incomplete source snapshots, meaning interrupted ones, are never copied.
It draws from the source repository’s concurrency pool
Section titled “It draws from the source repository’s concurrency pool”kopia snapshot migrate reads the source repository. So a run counts against that repository’s concurrency.maxConcurrentJobs, alongside its backups and restores. The destination’s own cap does not gate it.
Throttling, covered below, shapes how hard one run pulls. The cap bounds how many things pull at once.
A run that arrives at a full pool is parked before its mover Job is created, with no side effects of any kind. It reports RepositorySlotAvailable=False, with reason WaitingForSlot.
It launches on its own when a slot frees, and the condition heals to True with reason SlotAcquired. If that heal write ever fails, it is retried while the run is in flight, and failing that, when the next run starts. See Backups → limiting concurrent jobs per repository.
Throttling a replication
Section titled “Throttling a replication”A replication is often the heaviest thing Kopiur does to your network. It reads a whole history out of one repository and writes it into another, frequently across a wide-area link that other people are also using.
spec.migrate.throttle caps that, per side, because a replication involves two repositories.
migrate: throttle: source: # reading out of nas-primary (on-LAN, be generous) downloadBytesPerSecond: 209715200 # 200 MiB/s destination: # writing to the off-site bucket (the scarce link) uploadBytesPerSecond: 10485760 # 10 MiB/s writeOpsPerSecond: 100Each side takes the same four knobs as a repository’s moverDefaults.throttle: uploadBytesPerSecond, downloadBytesPerSecond, readOpsPerSecond, and writeOpsPerSecond.
Every knob you set must be at least 1. A 0 is rejected at admission, because kopia’s “no limit” is an absent knob, not a zero.
There are two layers, merged field by field. Each repository can already declare moverDefaults.throttle, which every mover that touches it honors. migrate.throttle sits on top of that, per side:
throttle.sourceoverrides the source repository’s defaults.throttle.destinationoverrides the destination repository’s.- The merge happens per knob. A knob you set here wins; a knob you leave unset keeps the repository’s value. Setting one knob never drops the repository’s others.
- Omit
migrate.throttleentirely and each side simply uses its own repository’s defaults.
The two sides never mix. A cap on the source constrains only the read connection, because kopia’s limits are per connection, and a replication opens two connections under two separate kopia configurations.
Pruning the copies
Section titled “Pruning the copies”pruning is exactly one of three modes, and leaving it out means none.
Whatever the mode, pruning only ever considers snapshots this replication created, meaning the copy CRs it labels. It never touches the destination’s own directly-written snapshots, and never another replication’s copies.
| Mode | Behavior |
|---|---|
none (default) |
Never prune. Copies accumulate until you delete them, or delete the CR they became. |
mirrorSource |
Delete a copy when its (identity, startTime) has vanished from the source, so the destination tracks the source’s own retention. |
retention |
Apply independent GFS retention over the copies at the destination, regardless of what the source still holds. It takes keepDaily, keepWeekly, and the rest, in the same shape a SnapshotPolicy uses. A retention: block that keeps nothing is rejected at admission, exactly as on a policy. |
Operational notes
Section titled “Operational notes”- Copies survive the CR. There are no ownerReferences, so deleting the
SnapshotReplicationleaves every copySnapshotCR, and its data, in place. To remove copies, delete thoseSnapshotCRs. TheirdeletionPolicy: Deletecascades to the destination repository, subject to its breaker. Or let apruningmode do it. - Both repositories must be
Ready, and the destination writable. The controller holds runs with theWaitingForSourceRepositoryandWaitingForDestinationRepositoryconditions. Amode: ReadOnlydestination stalls withDestinationReadOnly. - Different passwords are fine, and expected. The source is opened read-only with its own credentials, and the destination with its own. Nothing about the two repositories needs to match. Different backends, formats, and passwords all work.
- Identity overlap with destination-side policies is guarded. Suppose a
SnapshotPolicywrites directly into the destination and produces the same kopia identity as a copied snapshot. The two histories would then interleave. The webhook denies that combination outright whenpruning: mirrorSourceis set, because the prune would eat the policy’s own snapshots, and warns otherwise. At runtime the controller re-checks each pass and surfaces anIdentityOverlapcondition, skipping the run undermirrorSource. - A dedicated mover ServiceAccount. Replication movers create, patch, and delete
SnapshotCRs, which are the copies. The ordinary backup mover must never be able to do that. So they run as the dedicatedkopiur-snapshot-replication-moverServiceAccount, with its own narrowly-scoped Role, generated alongside the rest of the RBAC. - No destination maintenance behind your back. The mover always disables kopia’s auto-maintenance. The destination’s own
Maintenancestays the only compaction that runs there. - Every run is counted.
kopiur_replication_runs_total{kind,trigger,outcome}records each finished run, so “the nightly copy has been failing” is alertable without watching conditions.triggerseparatescronfrom the requested runs. - Size the first run. A full-history first replication of a large repository moves everything once, and is idempotent after that. Raise
migrate.parallel, considerlatestOnly: truefor seeding, and note that the mover Job’s deadline can be tuned through themoverandfailurePolicyknobs on big estates. If that first run is what worries the network, cap it withmigrate.throttlerather than by shrinking the selection. Leave headroom, though, or the deadline becomes the thing that fails.
See also
Section titled “See also”- Repository replication: the blob-level mirror, for when the destination is a passive copy.
- Multi-repository fan-out: backing up into several repositories directly from one
SnapshotPolicy, and why combining hooks with fan-out points you back here. - Repositories & backends: the catalog,
deletionProtection, andidentityDefaults. - Disaster recovery scenario
- Scenario 10, DR from a replicated repository: the one-shot counterpart.
Repository.spec.seedcopies a whole repository in at first bootstrap, using the samekopia snapshot migrateunderseed.from.repository, instead of copying selected snapshots on a schedule.