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.
When to reach for it
- One off-site repository that is both a mirror target and a live backup target.
RepositoryReplication, which runskopia repository sync-to, copies blobs exactly as they are. Its destination must therefore be a passive, identical mirror with exactly one writer. It cannot at the same time be a repository that other policies back up into, because the sync would fight those direct writes over indexes and epochs. Snapshot replication instead writes through the destination's own front door, so the destination stays an ordinary first-class repository. - Consolidation. Copy several team repositories into one shared off-site
ClusterRepository. - Seeding. Populate a new repository from an old one. Set
latestOnly: truefor a cheap seed. - Selective copies. Copy only some identities, such as every
pg-*policy, or exclude scratch paths.
If all you want is a passive, identical off-site mirror of one repository, with the same password and restore-ready as it stands, RepositoryReplication is simpler and cheaper. Reach for SnapshotReplication when the destination must be a repository in its own right.
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¶
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/v1alpha1
kind: SnapshotReplication
metadata:
name: nas-primary-to-offsite
namespace: billing
spec:
# 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: false
Watch it:
$ kubectl -n billing get snapshotreplications
NAME SOURCE DESTINATION SCHEDULE PHASE LAST AGE
nas-primary-to-offsite nas-primary offsite-s3 0 6 * * * Succeeded 2m 1d
$ kubectl -n billing get snapshots -l kopiur.home-operations.com/origin=replicated
status.lastRun carries the per-run counters: identitiesSelected, snapshotsCopied, alreadyPresent, failed, and pruned.
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¶
| 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¶
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 --wait
snapshotreplication.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:51Z
If 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)" --overwrite
The 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.
A requested run re-anchors the schedule
The next cron slot is computed from status.lastReplicated, and a successful requested run stamps it exactly as a scheduled run does.
So running at 14:00 on an 0 6 * * * replication means the next automatic run is 06:00 tomorrow. That is intended. The cron means "at least this often", and a redundant run would only re-scan the snapshots you just copied.
Suspended? The request waits, it does not vanish
Requesting a run on a suspend: true replication records it as status.manualRun.phase: Pending, and surfaces Ready=False with reason SuspendedWithPendingRun.
Nothing starts until you resume it. At that point the still-unanswered request fires immediately.
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: false
Matching 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¶
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¶
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: 100
Each 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.
Why the cap isn't a flag on snapshot migrate
kopia snapshot migrate has no speed options at all. The only lever kopia offers is kopia repository throttle set, which writes the limits into a connection's config.
So Kopiur connects each side, applies that side's limits, and lets the migrate inherit them when it reopens those configs. That is also why each side needs its own block: there is nothing repository-wide to inherit. Applying a cap to the read-only source connection is fine, because kopia accepts throttle set there, so a mode: ReadOnly source is throttled like any other.
If either application fails, the run fails rather than proceeding uncapped. Saturating exactly the link you asked to protect is worse than not running.
The mover logs applied repository throttle once per capped connection, so twice for a healthy replication. That is the quickest way to confirm the limits landed:
A byte cap bites much harder than its number suggests
Byte-rate caps apply to cold backend traffic only. Content already in the mover's kopia cache is served without touching the limiter, so a warm re-run can look entirely unthrottled.
And on small-object workloads the real throughput lands far below the number you set. In one measurement, a 2 MB/s cap took about 14 s to move a 28 KiB cold repository, while caps of 10 MB/s and above often did not bind at all at that size.
Set these as a ceiling for large transfers, set them generously, and measure against your own data. A number picked to "feel safe" can turn a nightly replication into one that never finishes.
New CRD fields need kubectl apply, not just helm upgrade
migrate.throttle is a new CRD field, and Helm's crds/ directory is install-only. A helm upgrade never updates CRD schemas.
So on a helm-CLI upgrade, an apiserver running the old schema silently prunes the field from your manifest. The object admits cleanly, kubectl get -o yaml shows no throttle, and the runs stay uncapped with nothing to see.
Apply the CRDs first with kubectl apply --server-side -f deploy/crds/, or use a GitOps flow with a CreateReplace CRD policy. See CRD lifecycle.
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. |
mirrorSource meets the mass-deletion breaker, by design
mirrorSource deletes at the destination whatever disappeared at the source. To stop that from becoming an attack path, mirror-source deletes are deliberately not stamped as operator prunes. They count as external deletions against the destination repository's mass-deletion breaker, which is deletionProtection.threshold, defaulting to 10.
So a bulk disappearance at the source, whether ransomware emptying it or a fat-fingered mass delete, is held at the destination instead of cascading into the off-site copy in one wave. The hold shows up as DeletionHeld on the affected copy Snapshot CRs, and MassDeletionHeld on the destination repository. Release it with the breaker's normal timestamp acknowledgement, once you've confirmed the deletions are intended.
retention-mode prunes, by contrast, are operator prunes. They are stamped pruned-by: replication-retention and they bypass the breaker, because they are bounded, GFS-selected, and started by your own spec.
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¶
- 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.