Backups & schedules
Backing up is three resources, not one, and keeping them separate is the whole point. This page explains what each does, then walks the handful of fields you’ll actually change.
All three are namespaced and live in the same namespace as the PVCs they back up. That’s where the mover Job runs; see Movers, RBAC & credentials.
Try it end-to-end
Section titled “Try it end-to-end”Want to watch a real backup move real data before reading the field reference? This one bundle is self-contained: a namespace, a PVC for the kopia repository, a KOPIA_PASSWORD Secret, an app-data PVC, a busybox seed Job that writes files into it, a filesystem Repository, a SnapshotPolicy, and a manual Snapshot. No cloud credentials are needed, because the repository lives on a PVC, so the only thing to fill in is the password.
The recipe, the SnapshotPolicy, ties the seeded PVC to the repository and sets retention. This is the heart of what you’re applying:
apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: kopiur-tryitspec: repository: name: primary # kind defaults to Repository (same namespace) sources: - pvc: name: app-data # the PVC the seed Job wrote into retention: keepDaily: 7 keepWeekly: 4The seed Job writes 20 files into app-data so the snapshot has real data to upload. Without it, status.stats would report zeros:
# Write real files into app-data so the backup has something to upload. The arc# waits for this Job to complete before taking the Snapshot, so# status.stats.filesNew / bytesNew come back non-zero.apiVersion: batch/v1kind: Jobmetadata: name: seed-data namespace: kopiur-tryitspec: backoffLimit: 3 template: spec: restartPolicy: Never containers: - name: seed image: busybox:1.36 command: ["/bin/sh", "-c"] args: - | set -eu mkdir -p /data/docs for i in $(seq 1 20); do head -c 4096 /dev/urandom | base64 > /data/docs/file-$i.txt done echo "seeded $(ls /data/docs | wc -l) files" volumeMounts: - name: app-data mountPath: /data volumes: - name: app-data persistentVolumeClaim: claimName: app-data1. Wait for the seed Job and the Repository. The seed Job writes 20 files into app-data so the backup has something to upload, and the Repository initializes a fresh kopia repository on its PVC:
$ kubectl -n kopiur-tryit wait --for=condition=complete job/seed-data --timeout=2m$ kubectl -n kopiur-tryit wait --for=condition=Ready repository/primary --timeout=2m2. Take the backup. This is the one resource you create rather than apply. It uses generateName, so each create mints a fresh name:
$ kubectl create -f deploy/examples/tryit/backups.yamlsnapshot.kopiur.home-operations.com/app-data-manual-abc12 created
$ kubectl -n kopiur-tryit get snapshots -wNAME PHASE ORIGIN SNAPSHOT AGEapp-data-manual-abc12 Pending manual 2sapp-data-manual-abc12 Running manual 7sapp-data-manual-abc12 Succeeded manual k8f3c1a90 44s3. Prove it backed up real data (deep). status.stats must show non-zero filesNew and bytesNew. An empty backup would report zeros:
$ kubectl -n kopiur-tryit get snapshot app-data-manual-abc12 \ -o jsonpath='{.status.stats}'{"sizeBytes":...,"bytesNew":...,"filesNew":20,"filesUnchanged":0}(Illustrative: byte/size figures vary; filesNew reflects the 20 seeded files.) The Job’s logs, via kubectl -n kopiur-tryit logs job/seed-data, confirm 20 files were written.
4. Confirm the kopia snapshot exists. status.snapshot.kopiaSnapshotID, with a capital ID, is the handle kopia stores the data under. Its presence is the proof the snapshot is in your repository:
$ kubectl -n kopiur-tryit get snapshot app-data-manual-abc12 \ -o jsonpath='{.status.snapshot.kopiaSnapshotID}'k8f3c1a90 # illustrative — your ID differsTo tear down, run kubectl delete namespace kopiur-tryit. The Snapshot finalizer runs kopia snapshot delete first.
SnapshotPolicy — the recipe
Section titled “SnapshotPolicy — the recipe”A minimal recipe is a repository, a source, and a retention policy. This is the recipe stage of example 01:
apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: postgres-data namespace: billingspec: # repository.kind defaults to Repository; shown explicitly for clarity. repository: kind: Repository name: nas-primary sources: - pvc: name: postgres-data # GFS retention is the ONLY successful-retention driver. retention: keepDaily: 14 keepWeekly: 4Repositories — one recipe, several repositories (fan-out)
Section titled “Repositories — one recipe, several repositories (fan-out)”A recipe targets its repositories with exactly one of two fields. repository names one Repository or ClusterRepository, which is what everything above and below this section assumes. repositories is a list of 1 to 8 distinct refs, and it backs every source up into each of them on every run. See example 40:
apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: postgres-data namespace: billingspec: # `repositories` (1-8 distinct entries) replaces `repository` — exactly one # of the two fields may be set. Every firing backs the source up into EACH # listed repository: one Snapshot CR + one mover Job per (source × repo). # A ClusterRepository entry works too (kind: ClusterRepository). repositories: - kind: Repository name: nas - kind: Repository name: offsite-s3 sources: - pvc: name: postgres-data # Retention is applied PER (source, repository): keepDaily: 14 keeps 14 # dailies in `nas` AND 14 in `offsite-s3` — a repo outage never lets the # healthy repo's history evict the broken one's. retention: keepDaily: 14 keepWeekly: 4 # NOTE: `hooks:` is refused on a multi-repo policy — N concurrent children # cannot honor a freeze/thaw quiesce contract. For hooked backups plus a # second repository, keep the policy single-repo and add a # SnapshotReplication (example 39).The mental model: each firing expands to one Snapshot CR plus one mover Job per (source × repository). Every child is an ordinary single-repo backup from there on, with its own kopia snapshot, its own retention bucket, and its own verification, pinned to its repository via spec.repository stamped when the child is created. Child names gain a -repo-<name>-<hash> marker, while single-repo children keep their exact legacy names. Each repository also gets its own mover cache PVC, named kopiur-cache-<policy>-<repo>-<hash>, where single-repo keeps kopiur-cache-<policy>, because two kopia repositories can never share one cache.
What “per repository” means in practice:
- Retention is per (source, repository).
keepDaily: 7over 2 repositories keeps seven dailies in each, never seven total, so an outage of one repository can’t let the healthy one’s history evict the broken one’s records. Failed-run history, bounded byfailedJobsHistoryLimit, is likewise counted per repository. - Identity is per repository. Each child resolves its kopia identity under its own repository’s
identityDefaults. So one recipe legitimately has N identities, one per repository, and the identity-change and fork guards check every (identity, repository) pair. - Verification is per repository, with one verify Job per repo per slot.
status.verificationlists each repository’s result, and the flatstatus.lastVerifiedis the oldest timestamp across the current repository set. “Verified” means every repository is verified, so one unverifiable repo keeps the flat timestamp honest. - Captures are independent, not a single point in time across repositories. Each repository’s child stages and reads the source on its own, so the N snapshots of one slot are close in time but not one consistent instant across repositories. For a multi-PVC selector with
groupBy: VolumeGroupSnapshot, each repository gets its own VolumeGroupSnapshot per slot, so budget N times the CSI snapshot quota and storage-side load. - Deletion protection is per repository. Deleting a multi-repo policy’s history trips each repository’s mass-deletion breaker independently, so expect one acknowledgement per repository, not one total.
kubectl kopiur doctorgroups the held sets per repo with the exact acknowledgement command for each. - Restores must pick a repository. A
fromPolicyrestore against a multi-repo policy is refused untilspec.repositorynames one member of the policy’s set, and the message lists the valid choices. A ref that isn’t a member is refused too. Kopiur never guesses repository number 1, because the N captures can diverge. See Restores. concurrencyPolicy: Forbid, the schedule default, holds the whole slot. While any child of the previous slot is still running, and one slow or unreachable repository is enough, the next slot waits for all repositories. The same goes for any otherRunningrun of the policy, such as a manualkubectl kopiur snapshot now. UseAllowif a lagging repository must not delay the healthy ones’ cadence.- The cross product is capped at 400 children per slot, meaning sources times repositories. A slot over the cap is skipped whole, never partially created, with a
FanoutCappedcondition on the schedule and adoctorfinding. kubectl get snapshotpolicyrenders the single-repoRepositorycolumn blank for a multi-repo policy. TheRepositoriescolumn, fromstatus.repositorySummary, lists the set.
Sources — what to back up
Section titled “Sources — what to back up”sources is a list. Each entry is exactly one of a single PVC, a label selector, an inline NFS export, or a streamed command. They are mutually exclusive, and the webhook rejects setting more than one on a source.
sources: - pvc: name: postgres-data # one PVC by nameOr match many PVCs at once. See example 04:
sources: - pvcSelector: labelSelector: matchLabels: { backup: include } sourcePathStrategy: PvcName # or PvcNamespacedName to disambiguate same-named PVCsA pvcSelector expands to one Snapshot CR per matched PVC. Each is an ordinary single-PVC backup from there on: its own mover Job, its own kopia snapshot, its own retention. That is deliberate. It keeps every other feature working on a multi-PVC recipe with no special cases, including restore, deletionPolicy, the catalog and GFS retention.
Consequences worth knowing:
- Retention is per PVC.
keepDaily: 7over a 5-PVC selector keeps seven days of each volume, not seven snapshots in total. - The expansion happens when a backup is invoked, not when you write the policy. That means at a
SnapshotSchedulefire, or atkubectl kopiur snapshot now. A PVC that gains the label is picked up at the next slot, and one that loses it simply stops being backed up. - A hand-written
Snapshotwith only apolicyRefis rejected against a selector policy. It does not say which PVC it covers, and the operator will not pick one of N on your behalf. Usekubectl kopiur snapshot nowor a schedule; both expand for you. - The selector only matches PVCs in the policy’s own namespace.
namespaceSelectoris refused, because a mover Pod can only mount PersistentVolumeClaims in its own namespace, and the mover Job runs in theSnapshot’s namespace, which is the policy’s. Use oneSnapshotPolicyper namespace; they can share a repository. - Two selector sources may not match the same PVC. Both would resolve to one kopia source path and one
Snapshotname, so one of the two backups would silently overwrite the other. Narrow the selectors instead. sourcePathOverrideon a selector source is a footgun. It is one literal path, so it only works while the selector matches exactly one PVC. The moment a second PVC matches, both would land on that same kopia source, merging two volumes’ histories into one stream where they also prune each other — so the backup is refused at run time and no further snapshots are minted for that source. Watch the failed schedule fire, not verification: the snapshots taken before the second PVC appeared are still at that path, so verification keeps passing while backups have quietly stopped. It is not rejected at admission, because a single-match policy with a custom path genuinely works and rejecting it would park the whole recipe. PrefersourcePathStrategy, which derives a distinct path per PVC, or put the override on its ownpvc:source.sourcePathStrategyis part of your data identity.PvcNamegives each PVC the kopia path/pvc/<name>;PvcNamespacedNamequalifies it with the namespace. Changing the strategy later re-identifies every source, so it is guarded like any other identity change. See Identity.groupBydecides whether the captures are crash-consistent with each other. See copy methods → multi-PVC and consistency groups.
Or back up an NFS export directly, with no PVC. See example 10:
sources: - nfs: server: expanse.internal # NFS server hostname or IP path: /mnt/eros/Media # the export (an absolute path)The operator mounts the export read-only into the backup mover and kopia snapshots it. By default kopia records the export path as the snapshot sourcePath; override it with sourcePathOverride. An NFS source works with any repository backend.
Or capture a command’s stdout as one virtual file, with nothing mounted at all — the logical-backup source for a database dump. See Streamed command sources and example 45:
sources: - stream: fileName: postgres.sql # the one virtual file in the snapshot workloadExec: podSelector: matchLabels: { app: postgres } command: ["sh", "-ec", "pg_dumpall -U postgres"]The mover execs the command in the workload’s container and pipes its stdout straight into kopia, so the dump uses the credentials already present there and the mover never sees repository credentials. The snapshot root is a virtual directory at /stream/<fileName> — deliberately distinct from a PVC source’s /pvc/<name>, so a streamed artifact and a volume backup can never share a kopia identity. Exactly one Running pod must match podSelector; zero, several, or only not-running matches are each a named failure rather than an arbitrary pick.
A stream source needs pods/exec in the workload namespace, which is a much larger grant than an ordinary backup, so it is gated behind its own namespace opt-in that fails closed. Restoring one is the mirror streamExec target, which pipes the file back into a command’s stdin. Both are covered on the Streamed command sources page.
readOnly — the source mount
Section titled “readOnly — the source mount”Sources are mounted read-only, meaning readOnly: true, the default, because kopia only ever reads them. There is exactly one reason to change that. The kubelet applies fsGroup by rewriting the volume’s group ownership, and it skips that rewrite entirely on a read-only mount, so a mover’s fsGroup has no effect on the source. If you need fsGroup to make the source readable, set readOnly: false:
sources: - pvc: { name: postgres-data } readOnly: false # let the kubelet apply the mover's fsGroupUnder copyMethod: Snapshot or Clone this rewrites the throwaway staged PVC and never touches your data. Under copyMethod: Direct it rewrites your live volume and requires an explicit acknowledgeLiveMutation: true. On an nfs source it is rejected outright, because the kubelet never applies fsGroup to in-tree NFS, so it could not work. The full treatment, including the hazards, is at Copy methods → making fsGroup apply to the source.
How the source is captured — copyMethod
Section titled “How the source is captured — copyMethod”copyMethod |
What happens | Requires |
|---|---|---|
Snapshot (default) |
Point-in-time CSI VolumeSnapshot → temporary staged PVC → kopia reads the stage. |
The CSI snapshot stack + a VolumeSnapshotClass for your driver. |
Clone |
CSI clone of the source PVC → kopia reads the clone. | A CSI driver that supports volume cloning. |
Direct |
Read the live PVC directly (co-located on its node). | Nothing; works on any storage. |
volumeSnapshotClassName selects the snapshot class when Snapshot or Clone is used. Leave it unset, or empty, which means the same thing, to auto-pick your driver’s default class.
Retention — how long backups are kept (GFS)
Section titled “Retention — how long backups are kept (GFS)”Retention is grandfather-father-son, and it is the only thing that prunes successful backups. Kopiur enforces it by deleting Snapshot CRs outside the window, which, with the default deletionPolicy, deletes the underlying snapshots too.
retention: keepLatest: 10 # keep the N most recent regardless of age keepHourly: 24 keepDaily: 14 keepWeekly: 8 keepMonthly: 12 keepAnnual: 3Set only the buckets you care about and omit the rest. There is deliberately no successfulJobsHistoryLimit: successful retention is GFS, full stop. Failed runs are bounded separately by failedJobsHistoryLimit on the SnapshotSchedule.
How many Snapshot CRs will I have?
Section titled “How many Snapshot CRs will I have?”Kopiur keeps one Snapshot CR per retained snapshot per source. The CR is the backup’s lifecycle handle, carrying the finalizer and deletionPolicy, so the live CR count equals the retained snapshot count by design. It is set by retention, not by how often you schedule: a faster schedule fills the GFS window sooner but never exceeds it. Three independent populations add up:
- Successful backups, bounded by GFS
retention. The per-source upper bound is roughly the sum of the buckets you set (keepLatest + keepHourly + keepDaily + …, before GFS de-dups the overlap), times your number of sources. A policy withkeepHourly: 24, keepDaily: 14, keepWeekly: 8retains about 40 CRs per source. - Failed backups, bounded by
SnapshotSchedule.spec.failedJobsHistoryLimit, default10per schedule. - Discovered snapshots, materialized from the repository and bounded by
spec.catalog.retain, meaningperIdentityandmaxAgeDays, on theRepositoryorClusterRepository.
A cluster with around 30 sources on a typical GFS window sits at roughly 1000 to 2000 Snapshot CRs. Each is a few KiB in etcd, so tens of MB in total, which is comfortably fine, and each terminal CR only re-reconciles as a no-op roughly every 45 minutes. So this scales. Because retention sets the count, a sub-hourly schedule paired with a wide retention is the way to accidentally accumulate thousands of CRs. The admission webhook emits a heads-up warning for sub-hourly crons, which is a warning, not a limit.
Identity — what kopia records (username@hostname:path)
Section titled “Identity — what kopia records (username@hostname:path)”kopia stores every snapshot under an identity. Kopiur re-resolves it from the live spec on every run, from the live SnapshotPolicy.spec.identity plus the live referenced repository’s identityDefaults. It is not resolved once at admission and frozen. status.resolved.identity mirrors the most recent resolution; it is not a source of truth a later run reads back. The defaults:
usernamecomes from theSnapshotPolicynamehostnamecomes from the namespace, or<namespace>.<cluster>when the repository hasidentityDefaults.clusterset, which is how a repository shared across clusters workssourcePathcomes from/pvc/<pvcName>for a PVC source, or the exportpathfor annfssource
Override either part when you need stable identities across renames or clusters:
identity: username: postgres-data hostname: billingFor a shared ClusterRepository, the repo can supply identity CEL expressions, and for multi-cluster a cluster suffix, so tenants get distinct identities automatically. See Repositories → identityDefaults. An explicit identity here always wins.
compression, files & extraArgs — kopia tuning and ignores
Section titled “compression, files & extraArgs — kopia tuning and ignores”These map onto kopia’s per-source policy and sit as top-level siblings of retention on the SnapshotPolicy spec:
compression: compressor: zstd neverCompress: ["*.zip", "*.gz", "*.mp4"] # skip already-compressed filesfiles: ignoreRules: ["*.tmp", "*/cache/*"] # paths kopia skips, REPLACING the default set (see below) ignoreCacheDirs: true # honor CACHEDIR.TAG ignoreIdenticalSnapshots: false # take a new snapshot even if nothing changedextraArgs: [] # escape hatch for kopia flags not modeled above| Field | What it does |
|---|---|
compression.compressor |
The kopia compressor (e.g. zstd, gzip, s2); omit to leave content uncompressed. |
compression.neverCompress |
Globs to never attempt to compress: already-compressed media, archives. |
files.ignoreRules |
.gitignore-style globs of paths to exclude from the snapshot. |
files.ignoreCacheDirs |
Honor CACHEDIR.TAG markers (skip directories tagged as caches). |
files.ignoreIdenticalSnapshots |
When true, kopia won’t create a new snapshot if the source is exactly identical to the last one. |
extraArgs |
Pass-through kopia flags for anything not modeled above. |
The object splitter is not here. It is a repository property fixed at creation and lives on Repository.create.splitter, where it applies repository-wide.
files.ignoreRules default: OS-artifact excludes
Section titled “files.ignoreRules default: OS-artifact excludes”ignoreRules defaults to a 5-entry OS-artifact exclude set. That way a SnapshotPolicy that omits files entirely, or sets files: {}, never snapshots filesystem junk that is never intentional user data:
ignoreRules: - /lost+found # ext4/fsck recovery dir — anchored to the source ROOT only - System Volume Information # Windows/SMB-client artifact on samba-share PVCs - $RECYCLE.BIN # Windows/SMB-client artifact on samba-share PVCs - "@eaDir" # Synology NAS extended-attribute/thumbnail metadata junk - .snapshot # NAS-exposed snapshot pseudo-dirs (NetApp-style); UNANCHOREDWhy each entry is there:
/lost+foundis root-anchored, because of the leading/, so only the source root’s own ext4 fsck-recovery directory is excluded. A nested directory a user happens to namelost+founddeeper in the tree is left alone.System Volume Informationand$RECYCLE.BINare Windows and SMB-client artifacts that appear on samba-share-backed PVCs.@eaDiris Synology NAS extended-attribute and thumbnail metadata junk..snapshotcovers NAS-exposed snapshot pseudo-directories, NetApp-style. It is deliberately unanchored, with no leading/, because these appear at every level of a NetApp-backed export, not just the root, and backing one up recursively would multiply the backup size by re-capturing older snapshot generations as regular file data. The flip side of being unanchored is that a legitimate directory you happen to name.snapshot, at any depth, is also excluded. If you have one, setignoreRulesexplicitly, since your list replaces the default wholesale.
Recommended extras. This is desktop and editor junk that is deliberately not defaulted. Copy this list into your own ignoreRules alongside anything else you need, and remember it replaces the default, so include the default entries you want too:
files: ignoreRules: # Kopiur's defaults: - /lost+found - System Volume Information - $RECYCLE.BIN - "@eaDir" - .snapshot # Desktop/editor junk — opt in per-workload, not defaulted: - .DS_Store # macOS Finder metadata — harmless to drop, but not universal (server workloads never see it) - "._*" # macOS AppleDouble sidecar files — CAN carry real xattrs/resource forks; dropping by default risks silent data loss for some workloads - Thumbs.db # Windows Explorer thumbnail cache - desktop.ini # Windows Explorer per-folder display settings - .Trash-* # Linux desktop trash dirs — contents are recoverable deletions, but deliberately not auto-discarded by defaulterrorHandling — let a snapshot complete with errors
Section titled “errorHandling — let a snapshot complete with errors”This is the backup-side counterpart to restore’s ignorePermissionErrors. Each flag is off by default, meaning kopia fails on the error. Turn one on to let the snapshot finish anyway:
errorHandling: ignoreFileErrors: true # --ignore-file-errors: skip unreadable files ignoreDirErrors: false # --ignore-dir-errors: skip unreadable directories ignoreUnknownTypes: true # --ignore-unknown-types: skip sockets/devices/... failFast: false # --fail-fast: abort at the FIRST error instead of collecting and continuingfailFast works in the opposite direction from its three siblings above: it makes the snapshot less tolerant of errors, not more. It’s a kopia snapshot create argv flag, not a policy set setting, which is why it lives here beside its semantic opposites rather than under upload.
upload — parallelism
Section titled “upload — parallelism”This is kopia’s upload policy. Every setting is optional, and absent leaves kopia’s default:
upload: maxParallelSnapshots: 4 # --max-parallel-snapshots: concurrent sources maxParallelFileReads: 8 # --max-parallel-file-reads: file-read concurrency limitMb: 500 # --upload-limit-mb: abort the snapshot after this many MB uploaded (kopia default: unlimited)limitMb is named that way to avoid the upload.uploadLimitMb stutter. Like failFast, it’s a snapshot create argv flag rather than a policy set setting, but it lives here beside its parallelism siblings.
verification — prove the snapshots are restorable
Section titled “verification — prove the snapshots are restorable”Verification is opt-in. When absent, nothing runs. When set, the operator runs a frequent blob-level kopia snapshot verify (quick), a rarer scratch-restore test (deep), or both, on a cron. It surfaces status.lastVerified, and with successExpr it asserts the result is good:
verification: quick: # blob-level `kopia snapshot verify`, often schedule: { cron: "0 4 * * *", jitter: 30m } parallel: 8 # --parallel: verification parallelism (kopia default: 8) fileParallelism: 4 # --file-parallelism: parallelism for file verification fileQueueLength: 20000 # --file-queue-length (kopia default: 20000) maxErrors: 0 # --max-errors: stop after this many errors (0 = stop at first) deep: # scratch-restore the latest snapshot into a throwaway volume, rarely schedule: { cron: "0 5 * * 0", jitter: 1h } capacity: 100Gi # size a fresh ephemeral PVC for the restore (omit = emptyDir) storageClassName: fast-ssd # StorageClass for that PVC (omit = cluster default) parallel: 4 # restore --parallel: deep verify IS a restore under the hood successExpr: "stats.files > 0 && stats.errors == 0" # CEL pass/fail predicate verifyFilesPercent: 10 # how much of each file `quick` reads fullyquick’s four tuning settings, parallel, fileParallelism, fileQueueLength and maxErrors, map directly onto kopia snapshot verify’s own flags. All are optional, and absent leaves kopia’s own default. deep.parallel maps onto restore --parallel, because the deep tier restores the snapshot under the hood, so its parallelism setting lives with the rest of the restore-shaped config. maxErrors: 0 is kopia’s own default, meaning stop at the very first error, so it’s deliberately unconstrained at admission, unlike the count settings, which must be >= 1.
Two tiers, because there are two different questions. quick asks “are the repository blobs and indexes intact?” It runs kopia snapshot verify, reads metadata, and spot-checks a slice of file contents per verifyFilesPercent. It is cheap, so you run it often. deep asks the stronger question, “can I actually get my data back?” It restores the latest snapshot into a throwaway volume, checks the result, and discards it. That is the closest thing to a real recovery, so you run it rarely. Schedule the two independently.
Deep verify restores into a throwaway scratch volume, then discards it. capacity and storageClassName size and place that volume. Set capacity and the operator provisions a fresh generic-ephemeral PVC of that size, auto-deleted with the Job; size it to comfortably hold the restored snapshot. Omit capacity and scratch falls back to a node-ephemeral emptyDir, which is zero-config but bounded by node disk, and fine for small snapshots. storageClassName only applies when capacity is set, because a class with no capacity does nothing; an emptyDir has no StorageClass. The operator surfaces that as a ScratchStorageClassIgnored condition plus a Warning Event on the SnapshotPolicy.
You can set the scratch size and class once at the repository level via moverDefaults.scratch instead of repeating it on every policy. The verification.deep fields here override that repo default field by field.
verification scheduling — gated until there is something to verify
Section titled “verification scheduling — gated until there is something to verify”Verification only ever runs against a snapshot that actually exists. On a brand-new SnapshotPolicy the operator does not schedule a verify Job the moment verification is added. It waits until either:
- this policy has produced its first successful backup, meaning a
Snapshotthat reachedSucceeded, or - the resolved repository already carries a discovered (adopted) or replicated snapshot at this policy’s own kopia identity. That is the escape hatch for an adopted repository, where a deep verify legitimately restores the latest repo snapshot for the identity even though this policy has never run a backup itself. The identity has to match: on a repository shared with other policies or other clusters, someone else’s history says nothing about yours, and unlocking on it would just produce a failing verify Job every slot until your own first backup landed.
While gated, status.lastVerified stays unset and no verify Job is created. The SnapshotPolicy keeps reconciling on a steady background cadence rather than the tight polling it uses once a verify Job is actually in flight, because there is nothing wrong to report, just nothing to verify yet. As soon as the gate opens, which typically means the first backup succeeds, the operator catches up and runs the first due verification promptly, without waiting for the next cron slot. Before this gate existed, a fresh policy with verification configured but no backup yet could spawn a verify Job that failed hard against an empty repository (GitHub #168).
verification with a pvcSelector — one run per matched volume
Section titled “verification with a pvcSelector — one run per matched volume”A policy whose source is a pvcSelector backs up one kopia source per matched PVC, so verification checks one per matched PVC too. Each matched volume gets its own verify Job, carrying that volume’s own kopia source path, and its own recorded result.
You do not configure any of this. verification is written exactly as above; the fan-out follows the selector.
Where it shows up:
- Verify Jobs. One per matched PVC, named
<policy>-vfy-<q|d>-m<tag>-<slot>, where<tag>identifies the volume. ApvcSelectorpolicy uses this name even while the selector matches only one PVC; only a non-selector source (pvc:ornfs:) keeps the plain<policy>-vfy-<q|d>-<slot>name. status.verificationStamps. Each member records its own timestamp under its own key. ApvcSelectorpolicy always uses this map, again including the one-match case. Only a non-selector source keeps writing the flatstatus.lastVerifieddirectly, with no stamp map.status.lastVerified. The oldest member timestamp, so it reads as “every volume in this policy is verified as of T”. It stays unset until every member has verified at least once: a partially verified policy must not show a reassuring timestamp. The same rule applies one level up for a multi-repository policy, wherestatus.verification[].lastVerifiedis the oldest across that repository’s volumes.- A selector that currently matches nothing. Nothing is verified and nothing is stamped, and the operator logs a warning naming the policy. It never runs a verification that covers no volume.
- The gate is per volume, and per identity. The gate above applies to each member individually: a PVC that joins the selector later stays unverified until its own first backup succeeds, because a sibling’s success is no evidence about a volume the repository has never seen. The adopted-repository escape hatch is narrowed the same way: a discovered or replicated snapshot unlocks a member only when it carries that member’s kopia identity, so a policy sharing a repository with other identities is not unlocked by their history.
quick members of one repository run concurrently — they are short, read-only, metadata-heavy runs. deep members run one at a time, because each deep member provisions its own scratch volume of deep.capacity; four volumes at capacity: 500Gi would otherwise ask the cluster for 2 TiB of ephemeral storage at once. A held member starts as soon as the running one finishes, so a full deep drill of an N-volume policy simply takes N sequential restores. Only other deep runs hold a deep member back; a sibling’s quick run never defers the drill.
successExpr is a CEL predicate returning a bool over the verify result. The environment is:
stats{files,bytes,errors}, for both tiers.quickhas no machine-readable counts of its own, so the operator fillsstats.filesandstats.bytesfrom the verified snapshot’s manifest and reportserrors: 0on a passing run. That meansstats.files > 0 && stats.errors == 0works onquick, not justdeep.snapshot, the snapshot metadata, wheresnapshot.idis the verified snapshot’s id.restored{files,checksumMatches}, the scratch-restore result, deep only. It is empty forquick, so guard a deep-only reference withtier.tier, either"quick"or"deep", so one predicate can branch per tier, for exampletier == 'deep' ? restored.checksumMatches : stats.files > 0.
It is validated at admission, so a typo or an out-of-scope variable is rejected on kubectl apply. See the verification-drill scenario.
suspend — pause a recipe
Section titled “suspend — pause a recipe”suspend: true makes the operator skip this SnapshotPolicy entirely: no retention prune, no backups created by schedules, no verification, without deleting it. It is surfaced in the SUSPENDED printer column. suspend is now also available on Repository, ClusterRepository and RepositoryReplication.
hooks — quiesce the app around the snapshot
Section titled “hooks — quiesce the app around the snapshot”Hooks run in the workload, not the mover, before and after the snapshot. The classic use is flushing or locking a database so the snapshot is consistent. There are three forms, and each hook entry uses exactly one:
hooks: beforeSnapshot: - workloadExec: # exec into a workload pod/container podSelector: matchLabels: { app: postgres } container: postgres command: ["/bin/sh", "-c", "pg_backup_start"] timeout: 2m afterSnapshot: - workloadExec: podSelector: matchLabels: { app: postgres } container: postgres command: ["/bin/sh", "-c", "pg_backup_stop"]The other two forms are runJob, which runs a full one-shot Job owned by the Snapshot and is the k8up PreBackupPod equivalent, and httpRequest, which calls a URL, defaulting to POST. For httpRequest, a URL of the form http://user:pass@… becomes Basic auth, and an optional headers list of {name, value} objects rides along. A worked manifest with all the settings is example 20.
Behavior you can rely on:
- A hook failure aborts the backup. You get
Failedplus aHooksSucceeded=Falsecondition naming the hook and the cause, unless that hook setscontinueOnFailure: true. An aborted backup never creates its mover Job; create a newSnapshotonce the hook is fixed. afterSnapshothooks run whether the backup succeeded or failed. The canonical pairing is quiesce and resume, and a failed backup must not leave your database quiesced.- Each list runs exactly once per
Snapshot, stamped onstatus.hooks.preCompletedAtandpostCompletedAt, so requeues and controller restarts never repeat a side-effecting command. - Each hook is bounded by its
timeout, a Go-style duration defaulting to 5m, so a wedged quiesce fails the hook rather than hanging the backup forever.
mover — resources, cache, security context
Section titled “mover — resources, cache, security context”spec.mover tunes the mover Job that actually reads your data for this recipe, meaning the pod that runs kopia. The exact same mover block is available on a Restore and a Maintenance; see Restores → mover. Every field is optional. Omit the block entirely and you get an unprivileged mover, UID 65532, with an emptyDir cache and no resource limits. The fields, and when to set each:
mover: resources: # standard core/v1 ResourceRequirements for the mover container requests: { cpu: 250m, memory: 512Mi } limits: { cpu: "2", memory: 4Gi } securityContext: # standard core/v1 (CONTAINER) SecurityContext — UID/GID match runAsUser: 1000 runAsGroup: 1000 runAsNonRoot: true allowPrivilegeEscalation: false capabilities: { drop: ["ALL"] } seccompProfile: { type: RuntimeDefault } podSecurityContext: # standard core/v1 (POD) PodSecurityContext — notably fsGroup fsGroup: 1000 # make a fresh restore volume writable by an unprivileged mover fsGroupChangePolicy: OnRootMismatch # inheritSecurityContextFrom: # ...OR copy the securityContext from a live pod # pvcConsumer: {} # backup: auto-derive from the pod mounting the source PVC # # ...or name it explicitly: # # workloadSelector: { podSelector: { matchLabels: { app: postgres } }, container: postgres } cache: # kopia cache for this recipe (overrides the repository's moverDefaults.cache) capacity: 16Gi # size of the cache volume storageClassName: fast-ssd # cache volume's StorageClass (omit = cluster default) mode: Ephemeral # Ephemeral (default) | Persistent (warm cache across runs) contentCacheSizeMb: 10000 # kopia --content-cache-size-mb budget metadataCacheSizeMb: 2000 # kopia --metadata-cache-size-mb budget # privilegedMode: true # opt-in, namespace-gated; with runAsUser: 0 adds DAC_OVERRIDE (backup) / CHOWN,DAC_OVERRIDE,FOWNER,FSETID (restore)| Value | What it does | When to change it |
|---|---|---|
resources |
CPU/memory requests & limits on the mover container. | Large or many-file sources: give the mover memory headroom; or cap it so a backup doesn’t starve the node. |
securityContext.runAsUser / runAsGroup |
The UID/GID the mover runs as. Default UID 65532 reads only world-readable or 65532-owned files. |
Set it to the UID/GID that owns your data so the mover can read it. This is the single most common knob (see example 09 and Permissions). |
podSecurityContext.fsGroup |
A pod-level fsGroup (and fsGroupChangePolicy). On mount the kubelet makes the volume group-writable by that GID. |
Let an unprivileged mover populate a freshly-provisioned restore volume (root-owned 0755) without a root mover. A pod-level runAsUser: 0 here is still gated as privileged. See Security context → fsGroup. |
inheritSecurityContextFrom |
Copy both the container securityContext and the pod-level securityContext (e.g. fsGroup) from a live workload pod, instead of hard-coding them. Requires the workload to pin runAsUser. |
When you’d rather “run exactly as the app runs” (same UID and fsGroup) than track them. Combines with securityContext/podSecurityContext, which override it field-wise and act as the fallback when no pod resolves. See Security context and example 18. |
cache.capacity / storageClassName |
Back the kopia cache with a sized volume instead of an emptyDir. |
Large repositories: a sized cache avoids re-downloading metadata each run. |
cache.mode |
Ephemeral (fresh per run, GC’d with the Job) or Persistent (a controller-owned PVC reused across runs for a warm cache). |
Persistent for big recurring backups where a warm cache speeds each run. It’s ReadWriteOnce, so it assumes runs don’t overlap. |
cache.contentCacheSizeMb / metadataCacheSizeMb |
kopia’s content/metadata cache budgets (MiB). | Tune kopia’s memory/disk cache footprint independently of the volume size. |
privilegedMode |
For a root mover (runAsUser: 0, set yourself; privilegedMode never changes the UID), adds the Linux capabilities the operation needs on top of drop: ["ALL"]: DAC_OVERRIDE for a backup (read any file), CHOWN, DAC_OVERRIDE, FOWNER, FSETID for a restore (reproduce original ownership). runAsUser: 0 alone grants none of these, and privilegedMode without runAsUser: 0 grants nothing. See Security context → What privilegedMode grants. |
Only when matching a single UID isn’t enough (mixed ownership, lost+found). Namespace-gated; see below. |
A repository can set moverDefaults.cache that every mover inherits, and mover.cache overlays it field by field, so you can, for example, bump only capacity per recipe. See Repositories → moverDefaults.cache.
Snapshot — one snapshot, the universal trigger
Section titled “Snapshot — one snapshot, the universal trigger”You usually let a SnapshotSchedule create Snapshot CRs. To run one now, for a first-time test, an ad-hoc snapshot before a risky change, or from external automation, create one yourself. See example 06. It carries a policyRef, which says which recipe to run, and optional tags. Use generateName instead of name for repeatable ad-hoc one-shots:
apiVersion: kopiur.home-operations.com/v1alpha1kind: Snapshotmetadata: # A fixed name here (idempotent re-apply); use `generateName: postgres-data-manual-` # instead to let the API server append a unique suffix for ad-hoc one-shots. name: postgres-data-pre-migration namespace: billingspec: policyRef: name: postgres-data tags: reason: "pre-schema-migration" # Free-form text recorded on the kopia snapshot manifest itself # (`snapshot create --description`, up to 1024 characters). Per-invocation # by nature — scheduled/discovered Snapshots never set this. description: "pre-upgrade snapshot before the v14→v15 migration" # Keep the Snapshot CR even when the next GFS prune runs. deletionPolicy: Retain # Pin the underlying kopia snapshot so GFS retention NEVER expires it — for a # pre-migration / compliance hold. Clear it to release the # hold. `pin` exempts the snapshot from expiry; `deletionPolicy` governs what # happens to the snapshot when THIS CR is deleted — they're independent. pin: true # Optional per-run failure controls passed through to the mover Job (G6): failurePolicy: backoffLimit: 2 activeDeadlineSeconds: 7200 # hard wall-clock cap on a RUNNING mover (default: 48h backstop) podStartupDeadlineSeconds: 300 # fail fast if the mover can't START Running within 5m # (CreateContainerConfigError / ImagePullBackOff / Unschedulable) instead of hanging. Default 300.Watch it move through its phases:
$ kubectl get snapshots -n billing -wNAME PHASE ORIGIN SNAPSHOT AGEpostgres-data-manual-x9f Pending manual 2spostgres-data-manual-x9f Running manual 7spostgres-data-manual-x9f Succeeded manual k1f1ec0a8 44sORIGIN tells you where a Snapshot came from: scheduled from a SnapshotSchedule, manual from you or your automation, or discovered, materialized from snapshots Kopiur didn’t create. See Restores → discovered.
tags — label the snapshot in the repository
Section titled “tags — label the snapshot in the repository”spec.tags attaches free-form key: value pairs to the kopia snapshot manifest itself, via kopia snapshot create --tags, so they survive in the repository independently of the CR. They are visible to any kopia client and to other clusters sharing the repository:
spec: policyRef: { name: postgres-data } tags: reason: pre-upgrade ticket: OPS-1234Kopiur reserves the kopiur key prefix for the tags it writes on every produced snapshot:
| Tag (as passed to kopia) | Stored manifest key/value | What it records |
|---|---|---|
kopiur:config:<policy> |
key tag:kopiur, value config:<policy> |
Which SnapshotPolicy produced the snapshot (kopia’s first-colon split puts config:<policy> in the value, a long-standing shape existing tooling depends on). |
kopiur-meta: {…} |
key tag:kopiur-meta, value compact JSON |
The resolved mover identity the backup ran as: uid/gid (absent = image-determined), pod fsGroup, and src (whether the identity was inherited from the workload, pinned by the recipe’s explicit context, or came from defaults). |
The kopiur-meta value is mirrored to status.recorded at launch, and the catalog scan decodes it back onto discovered rows, and backfills pre-existing rows that lack it. So the identity your data expects survives cluster rebuilds with the repository itself. See Security context for how the identity is resolved.
deletionPolicy — what happens to the snapshot
Section titled “deletionPolicy — what happens to the snapshot”A Snapshot CR owns its kopia snapshot via a finalizer. What happens to the snapshot when the CR is deleted is governed by deletionPolicy:
| Policy | On Snapshot deletion |
Default for |
|---|---|---|
Delete |
Finalizer runs kopia snapshot delete, then removes the CR. |
scheduled / manual backups. |
Retain |
CR is removed; the snapshot stays in the repository. | discovered backups (forced, because Kopiur won’t delete what it didn’t create). |
Orphan |
CR is removed without contacting the repository. Escape hatch for “the bucket is already gone”. | — |
Set it per-Snapshot with spec.deletionPolicy, or set the recipe-wide default with SnapshotPolicy.spec.defaultDeletionPolicy. This is also how retention pruning reclaims space: pruned Snapshot CRs use Delete, so the snapshots go with them.
What happens when the schedule is deleted
Section titled “What happens when the schedule is deleted”A scheduled Snapshot‘s deletionPolicy says what happens when that CR is deleted. But a schedule’s produced Snapshots outlive the schedule that made them: they’re retained for their whole GFS window, long after the next cron slot. SnapshotSchedule.spec.deletion.onScheduleDelete governs what happens to those already-produced Snapshots’ kopia data when the schedule itself is deleted out from under them. That includes being deleted and recreated, or being Terminating under --cascade=foreground:
spec: deletion: onScheduleDelete: Delete # default: Retain| Value | What happens to a produced Snapshot whose own deletionPolicy is Delete, once its schedule is gone/replaced |
|---|---|
Retain (default) |
Downgraded to retain: the finalizer releases, no kopia snapshot delete runs, a SnapshotRetainedOnScheduleDelete Warning Event fires on the Snapshot, and the catalog rediscovers the kopia snapshot as origin: discovered (forced Retain) on its next scan. |
Delete |
Opt-in cascade: the Snapshot’s own deletionPolicy applies exactly as if the schedule still existed, so its kopia snapshot really is deleted, subject to the repository’s mass-deletion breaker below. |
This guard exists because Kubernetes’ own ownerReference garbage collection deletes a schedule’s produced Snapshot CRs the moment the schedule is deleted, or a GitOps tool replaces it, with no way for the CRs themselves to tell “my schedule is gone” apart from “someone deleted me directly”. And a schedule getting deleted, whether accidentally, by a flapping GitOps controller, or as part of a refactor, must never silently cascade into deleting a fleet’s worth of backup history. Retain is the fail-safe default for exactly the same reason onNamespaceDelete defaults to Orphan.
The guard only ever fires for external deletions. One of Kopiur’s own retention or failedJobsHistoryLimit prunes always honors the Snapshot’s real deletionPolicy regardless of the schedule’s state, because retention must keep working even if a schedule was just deleted. It also only fires while the schedule is genuinely gone or replaced: a live schedule deleting its own stale children, such as failedJobsHistoryLimit pruning, is an operator prune, not this path.
Editing spec.deletion.onScheduleDelete on a live schedule propagates to its existing produced Snapshots, as a best-effort patch on every reconcile, skipping any child already Terminating. So flipping the setting doesn’t only affect future firings, and you don’t have to touch each child by hand.
What happens when the policy is deleted
Section titled “What happens when the policy is deleted”SnapshotPolicy.spec.deletion.onPolicyDelete is the recipe-level counterpart to the schedule guard above. It says what happens to a recipe’s Snapshot CRs, meaning the ones carrying its config label kopiur.home-operations.com/config, when the SnapshotPolicy itself is deleted. A policy-cleanup finalizer on the SnapshotPolicy drives the cascade before the CR is actually removed:
spec: deletion: onPolicyDelete: Delete # default: Retain| Value | What happens to the policy’s Snapshot CRs once the SnapshotPolicy is deleted |
|---|---|
Retain (default) |
Every CR is stamped pruned-by: policy-cascade and removed, but every kopia snapshot stays in the repository. A SnapshotRetainedOnPolicyDelete Warning Event fires on each one whose own deletionPolicy was Delete (the loud downgrade). Nothing is lost: the data is rediscovered as origin: discovered on the next catalog scan, and (since adoption defaults on) automatically re-attached the moment a SnapshotPolicy with a matching identity exists (including this same policy, re-created). When the matching policy’s effective defaultDeletionPolicy is Retain/Orphan, only the history its retention window would keep is re-attached; the rest stays discovered (see catalog.adoption). |
Delete |
Opt-in cascade: each CR is deleted unstamped, so its own deletionPolicy applies exactly like any other external deletion. Delete really does call kopia snapshot delete, subject to the repository’s mass-deletion breaker (the allow-mass-deletion ack) below. |
Two things make Retain a genuinely safe default rather than a “kopia data now, CRs later” half-measure:
- Retain wins ties. If a
SnapshotScheduleand itsSnapshotPolicyare deleted at once, such as a GitOps prune removing a whole recipe bundle, the policy-cascade’spruned-by: policy-cascadestamp bypasses the schedule-deletion cascade guard above. The pair always drains toRetain’s outcome, CR removed and kopia data kept, never a race between the two guards. ASnapshotthat wasRunningat the time is cancelled: its CR, and the mover Job it owns, is deleted mid-run, so no kopia snapshot manifest for that run ever exists to retain. Any content the interrupted run had already uploaded is unreferenced and reclaimed by the repository’s ordinary full maintenance garbage collection, the same as any other interrupted kopia backup. - Discovered rows are never touched. The cascade only ever acts on
Adopted,ScheduledandManualchildren. Anorigin: discoveredSnapshotnever churns just because a policy it resembles, even one you hand-labeled it with, was deleted.
Escape hatches, the same as the schedule cascade:
kopiur.home-operations.com/skip-snapshot-cleanupon an individualSnapshotreleases just that CR without contacting the repository, overriding the cascade entirely. It is the same lever as above.- Removing the config label,
kopiur.home-operations.com/config, from aSnapshotdetaches it from the policy before you delete the policy. The cascade lists children by that label, so an unlabeled CR is simply invisible to it, and to retention.
Both modes, side by side:
# Example 35 — SnapshotPolicy.spec.deletion.onPolicyDelete: what happens to a# recipe's Snapshots when the SnapshotPolicy itself is deleted## A SnapshotPolicy owns a policy-cleanup finalizer that cascades onto every# Snapshot CR carrying its config label (kopiur.home-operations.com/config)# the moment the SnapshotPolicy is deleted. spec.deletion.onPolicyDelete picks# the mode:## - Retain (the default — shown below): every child CR is stamped# `pruned-by: policy-cascade` and removed, but NO kopia snapshot is ever# deleted, even for a child whose own deletionPolicy was Delete (a# SnapshotRetainedOnPolicyDelete Warning Event fires on each one, naming# why). The kopia data survives in the repository, is rediscovered as# `origin: discovered` on the next catalog scan, and — since adoption# defaults on — is automatically re-attached the moment a SnapshotPolicy# with a matching identity exists again (including this same policy,# re-created; see example 36).## - Delete (opt-in, commented out below): each child CR is deleted UNSTAMPED,# so its own deletionPolicy applies exactly like any other external# deletion — a Delete child's kopia snapshot really is removed. That still# passes through the repository's mass-deletion breaker# (spec.deletionProtection.threshold, default 10): deleting a whole# recipe's worth of Snapshots at once is exactly the bulk-deletion pattern# the breaker exists to catch, so a large policy will likely trip it and# need the kopiur.home-operations.com/allow-mass-deletion ack before the# held deletions proceed (see example 34 / docs/src/content/docs/repositories.mdx# "deletionProtection").## Field shapes verified against crates/api (externally-tagged backend:# `backend.s3`, not `backend: { kind: S3 }`).## See docs/src/content/docs/backups.mdx ("What happens when the policy is deleted") and# docs/src/content/docs/scenarios/adopt-existing-repo.mdx ("Delete a policy, then recreate it").---apiVersion: v1kind: Secretmetadata: name: nas-primary-creds namespace: billingtype: OpaquestringData: AWS_ACCESS_KEY_ID: "REPLACE_ME" AWS_SECRET_ACCESS_KEY: "REPLACE_ME" KOPIA_PASSWORD: "choose-something-long-and-random"---apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: nas-primary namespace: billingspec: backend: s3: bucket: my-backups prefix: prod/ endpoint: s3.us-east-1.amazonaws.com region: us-east-1 auth: secretRef: name: nas-primary-creds encryption: passwordSecretRef: name: nas-primary-creds key: KOPIA_PASSWORD create: enabled: true # The mass-deletion circuit breaker every Delete-mode cascade below still # passes through — see the Delete variant's comments further down. deletionProtection: threshold: 10 # default; `0` disables it---apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: postgres-data namespace: billingspec: repository: kind: Repository name: nas-primary sources: - pvc: name: postgres-data retention: keepDaily: 14 keepWeekly: 4 # Deletion semantics for the Snapshots THIS policy's config label governs. deletion: # Fail-safe default (shown explicitly here — omitting `deletion` # entirely, or the whole block, resolves to the same thing): deleting # this SnapshotPolicy removes its Snapshot CRs but keeps every kopia # snapshot in the repository. Nothing is lost — see the header comment. onPolicyDelete: Retain
# Opt-in cascade instead: uncomment to make deleting this SnapshotPolicy # really delete its Snapshots' kopia data too (each child's own # deletionPolicy applies as an ordinary external deletion, subject to # the repository's deletionProtection breaker above). # onPolicyDelete: DeleteSee Adopt an existing repo → Delete a policy, then recreate it for the full delete, recreate and adopt walkthrough.
How a deletion actually runs — batched, not one Job per Snapshot
Section titled “How a deletion actually runs — batched, not one Job per Snapshot”Kopiur never spawns one mover Job per Snapshot being deleted. Deletions for the same repository are aggregated into ONE batch mover Job. It is named snapdel-* and labeled kopiur.home-operations.com/op=snapshot-delete-batch. The Job lists the repository once, then deletes every member that is still present in a single kopia snapshot delete call, so the whole batch costs one repository open and one index load. Members whose snapshot is already gone count as done. This is what keeps a legitimate bulk deletion, such as a GFS retention sweep, a replication prune, a namespace teardown, or an acknowledged mass-deletion wave, from hammering the backend.
Pending deletions for a repository accumulate for a short quiet window of about 10 seconds before the batch fires, so a burst arriving within that window rides one Job instead of several. A batch holds up to 1,000 members, and fewer when long source paths would make the Job’s work spec too large, so a very large wave drains as a sequence of batches. A single deletion is simply a batch of one: it still waits out the quiet window, so expect up to about 10 seconds of added latency before an individual Snapshot’s finalizer clears, even outside any incident.
One batch at a time per repository. The next batch for a repository starts only when the running one finishes. kopia serializes manifest deletes on the repository’s index anyway, so parallel batches against one backend add no throughput and only multiply index downloads. The limit is spec.concurrency.maxConcurrentDeleteJobs on the Repository or ClusterRepository, default 1; see Repositories → concurrency. A batch Job that runs longer than 30 minutes is failed, and its members fire again in a fresh batch, so one wedged Job cannot stall a repository’s deletions for long.
Delete Jobs take the repository’s moverDefaults.cache. With mode: Persistent and the default single-flight limit, they share one warm cache PVC dedicated to that repository’s deletes, so each batch does not re-download the index. See Repositories → snapshot-delete Jobs use moverDefaults.cache too.
An optional cluster-wide cap additionally bounds how many batch Jobs may run at once across every repository. That is KOPIUR_MAX_CONCURRENT_DELETE_JOBS, or the Helm chart’s top-level maxConcurrentDeleteJobs value, default 0, meaning no cluster-wide cap. The per-repository limit is the primary defense, so leave this at 0 unless your cluster itself cannot host that many concurrent delete pods.
What Delete needs to succeed
Section titled “What Delete needs to succeed”Delete is a promise the operator has to keep, so the CR stays Terminating until the kopia snapshot is actually gone. It will not drop the finalizer and silently leave the snapshot behind. To do that it needs to reach the repository, which means it needs the repository’s credentials at deletion time, potentially long after the backup ran.
Kopiur removes the usual reasons that fails:
- The recipe may already be gone. The repository reference and the snapshot’s kopia identity are pinned into
statuswhen the run happens, so deleting theSnapshotPolicyfirst does not strand theSnapshot. The batch delete re-resolves the repository from that pinned value, not from the possibly-deleted recipe. - The delete runs where the credentials already live. The batch delete Job runs in the repository’s own home namespace, meaning a namespaced
Repository’s namespace, or the operator’s namespace for aClusterRepository, where the repository’s canonical credential Secret already exists. It reads that Secret directly, with no per-run credential copy to project and reclaim. That is a simplification over the earlier per-Snapshot delete Job, which had to project a short-lived copy into each Snapshot’s namespace.
What it cannot survive is the repository itself going away, or its owner revoking credentialProjection.allowed on a ClusterRepository. kopiur will not act against a withdrawn consent. If you hit that, or the bucket is simply gone, use the escape hatch below.
pin — exempt a snapshot from retention
Section titled “pin — exempt a snapshot from retention”Snapshot.spec.pin: true pins the underlying kopia snapshot so GFS retention never expires it, for a pre-migration or compliance hold. The reconciler applies a kopia snapshot pin, and clearing the field removes the pin. pin is independent of deletionPolicy: pin governs retention expiry, while deletionPolicy governs what happens to the snapshot when this CR is deleted.
spec: policyRef: { name: postgres-data } pin: true # GFS retention will skip this snapshot until you clear pindescription — annotate a one-off run
Section titled “description — annotate a one-off run”Snapshot.spec.description, up to 1024 characters, records free-form text on the kopia snapshot manifest via snapshot create --description. It’s per-invocation by nature, since a SnapshotSchedule’s children and discovered backups never set it. Use it on a manual Snapshot, or kubectl kopiur snapshot now --description, to note why this particular run exists:
spec: policyRef: { name: postgres-data } description: pre-upgrade snapshot before the v14→v15 migrationmover — override the recipe’s mover for one run
Section titled “mover — override the recipe’s mover for one run”Snapshot.spec.mover is the same MoverSpec a SnapshotPolicy and a Restore expose, and it exists for the case the recipe cannot serve: a one-off run that needs different mover settings than the nightly schedule. Without it, giving an ad-hoc pre-upgrade snapshot more memory or a different UID means editing the shared, GitOps-managed SnapshotPolicy that every scheduled run also uses — and remembering to edit it back.
It merges field by field over the recipe’s, which in turn merges over the repository’s moverDefaults:
Repository.moverDefaults < SnapshotPolicy.spec.mover < Snapshot.spec.moverThe highest layer that sets a field wins; a field you omit falls through. So a partial override adjusts only what it names, and it can only ever tighten the hardened base — it never drops capabilities.drop: [ALL] or the seccomp profile.
apiVersion: kopiur.home-operations.com/v1alpha1kind: Snapshotmetadata: # Server-named, so `kubectl create` (not apply) — a nameless object cannot be # applied. The generated suffix lets you repeat the command for each ad-hoc run. generateName: app-data-preupgrade- namespace: appspec: policyRef: name: app-data tags: reason: pre-upgrade # Per-RUN mover overrides, merged field-wise over the SnapshotPolicy's: # moverDefaults < SnapshotPolicy.spec.mover < Snapshot.spec.mover mover: # This app's archive directory is owned by a different UID than the rest of # its data; override just that for this one run. securityContext: runAsUser: 1001 # More headroom for a one-off full re-read. resources: requests: memory: 1Gi limits: memory: 4Gi # Per-run kopia cache budgets (see the note above). A `capacity`/ # `storageClassName` here would also size THIS Job's cache volume, since this # policy's cache is Ephemeral. cache: contentCacheSizeMb: 20000 # Keep this Job around longer than the 1h default so you can read its logs. ttlSecondsAfterFinished: 21600Every knob is available: resources, cache, securityContext, podSecurityContext, inheritSecurityContextFrom, privilegedMode, ttlSecondsAfterFinished. Three behaviors are worth knowing before you use it.
inheritSecurityContextFrom works here too, with the same backup-side rules: pvcConsumer and workloadSelector are valid, snapshot: {} is rejected at admission because it replays the identity recorded on a backup and a backup is what records one. Setting it on a Snapshot replaces the policy’s inherit source outright rather than blending two selectors, and the policy’s explicit securityContext still survives underneath as the inherit fallback.
failurePolicy — retry & deadline for the mover Job
Section titled “failurePolicy — retry & deadline for the mover Job”Snapshot.spec.failurePolicy controls the mover Job’s retry and wall-clock limits. It is the same surface a Restore has:
spec: policyRef: { name: postgres-data } failurePolicy: backoffLimit: 2 # retry the mover Job this many times before marking it failed (default 2) activeDeadlineSeconds: 3600 # kill a still-RUNNING backup after this many seconds (default: 48h backstop) podStartupDeadlineSeconds: 300 # fail a mover that can't START Running within this long (default 300 = 5m)| Value | What it does | When to change it |
|---|---|---|
backoffLimit |
Job.spec.backoffLimit: retries before the run is marked failed. |
Lower to fail fast on a flaky source; raise to ride out transient backend blips. |
activeDeadlineSeconds |
Job.spec.activeDeadlineSeconds: a hard wall-clock cap on a mover that is running. |
Set a ceiling so a long backup can’t run forever; size it above your largest expected run. Defaults to a 48h backstop. |
podStartupDeadlineSeconds |
How long the mover pod may sit unable to start (CreateContainerConfigError, ImagePullBackOff, or Unschedulable) before the run is failed with reason MoverPodWedged. |
Raise on slow nodes/large images or when an RWO volume takes a while to detach from another node; lower to surface a misconfiguration faster. Default 300 (5 min). |
Failed Snapshot CRs from a schedule are bounded by failedJobsHistoryLimit, described below. Successful ones are pruned by GFS retention.
A Snapshot runs exactly once
Section titled “A Snapshot runs exactly once”A Snapshot is one-shot. Once it reaches Succeeded, Failed, or Unchanged, the operator never runs its mover again. The finished mover Job self-removes after ttlSecondsAfterFinished, default 1h, see mover. That cleanup does not re-trigger the backup, and the recorded status.snapshot.kopiaSnapshotID and timing never change afterwards. The only things the operator still reconciles on a Succeeded snapshot are pin changes and deletion. A Failed snapshot stays failed until you create a new Snapshot, typically after fixing the recipe. Retries within a run are the failurePolicy above, never a silent re-run of a finished one.
SnapshotSchedule — the cron
Section titled “SnapshotSchedule — the cron”A schedule binds a recipe to a cadence and creates Snapshot CRs. This is the schedule stage of example 01:
apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotSchedulemetadata: name: postgres-data-nightly namespace: billingspec: # Which recipe (SnapshotPolicy) to invoke each firing. policyRef: name: postgres-data schedule: # Jenkins-style H pins a deterministic per-schedule minute. cron: "H 2 * * *" jitter: 30m # GitOps-friendly default: do not fire immediately on create. runOnCreate: falseThe schedule stage above is minimal. The full set of settings, timezone, suspend, concurrencyPolicy, startingDeadlineSeconds and failedJobsHistoryLimit, is described in the table below.
The fields you’ll change
Section titled “The fields you’ll change”| Field | What it does |
|---|---|
schedule.cron |
When to fire. Supports Jenkins-style H (see below). |
schedule.jitter |
Spread firings over a window (e.g. 30m), so many schedules don’t all hit at once. Absent, it inherits the target policy’s repository scheduleDefaults.jitter (see the tip below). Capped at 24h at admission. |
schedule.timezone |
IANA timezone the cron is evaluated in. Absent, it inherits the target policy’s repository scheduleDefaults.timezone, else UTC (see the tip below). |
schedule.runOnCreate |
false (default) means applying the schedule does not fire immediately, which is GitOps-friendly. Set true to backup the moment it’s created. |
schedule.suspend |
true pauses future firings (in-flight and past runs are untouched). |
schedule.concurrencyPolicy |
What to do if a run of the same policy is still in flight: Forbid (default, wait), Allow (run anyway), Replace (cancel this schedule’s old run; see below). Forbid and Replace also wait for runs this schedule did not start, such as a manual snapshot now. |
schedule.startingDeadlineSeconds |
If a slot is missed by more than this (operator was down), skip it rather than fire late. |
failedJobsHistoryLimit |
How many failed Snapshot CRs from this schedule to keep. Successful retention is GFS on the SnapshotPolicy. |
What concurrencyPolicy: Replace really cancels
Section titled “What concurrencyPolicy: Replace really cancels”A slot that comes due while this schedule’s previous run is still unfinished, meaning Pending or Running, doesn’t just get to skip the queue. Under Replace the controller deletes the old run before creating the new one, in this order:
- Deletes the run’s mover Job, so the pod doing the upload stops right away rather than lingering while ownership garbage collection catches up.
- Marks the old
SnapshotCRpruned-by: replaced-runand deletes it. That marking is what tells the deletion machinery this was your operator doing its job, not someone mass-deleting backups, so it doesn’t count against the repository’s mass-deletion breaker. Without it, a busyReplaceschedule would trip its own safety breaker. - Fires the new slot, in the same pass.
You’ll see one ReplacedActiveRun event on the schedule listing what was cancelled.
Replace will also decline to replace anything and simply wait, in three cases:
- The old run’s phase is one this operator doesn’t recognize. That is nearly always because a newer Kopiur wrote it during a partial upgrade. Rather than delete a run it can’t understand, the schedule stops and raises
ScheduleRunnable=Falsewith reasonBlockedOnUnreadableRunso you can see it. Finish the upgrade, or delete thatSnapshotif the run is really over. - The old run is queued behind the repository’s concurrency cap, showing
RepositorySlotAvailable=False. It isn’t running; it’s waiting in line. Cancelling it would free nothing and the replacement would go straight to the back of the same line, soReplacebehaves likeForbiduntil the queue drains. The schedule says so, withReplacementHeld=Truein its conditions plus a one-offWaitingForRepositorySlotevent, so a schedule that has quietly stopped firing always tells you why. This one resolves itself. If it keeps happening, raise the repository’s mover concurrency. - A run this schedule didn’t start is
Running, for the same policy: a manualkubectl kopiur snapshot now, another schedule’s run, or aSnapshotyou wrote by hand.Replaceonly ever cancels its own runs, so it never touches that one. It waits for it instead, exactly likeForbid, and cancels nothing in the meantime, not even its own previous run. Once the other run finishes, the slot replaces its own run as usual. The schedule recordsHeldByOtherRun=Truenaming the blockingSnapshot, plus a one-offOtherRunActiveevent. Only aRunningrun holds the slot; one stillPending(or stuck there) does not. The same hold applies to a new schedule’srunOnCreaterun: it waits for the other run to finish, then fires.
policyRef or policySelector — one recipe or many
Section titled “policyRef or policySelector — one recipe or many”A schedule targets recipes one of two mutually exclusive ways. Exactly one is required, and the webhook enforces that:
policyRef: { name: postgres-data }names a singleSnapshotPolicy. That is the common case, shown above.policySelectoris a label selector overSnapshotPolicyobjects in the schedule’s namespace. Each matching policy gets aSnapshotper firing. “Back up everything taggedtier=criticalnightly” becomes one object:
spec: # mutually exclusive with policyRef policySelector: matchLabels: { tier: critical } schedule: cron: "H 2 * * *" jitter: 30mInspect what the controller has computed:
$ kubectl get snapshotschedule -n billingNAME CONFIG SCHEDULE SUSPENDED AGEpostgres-data-nightly postgres-data H 2 * * * false 6d
$ kubectl get snapshotschedule postgres-data-nightly -n billing \ -o jsonpath='{.status.nextSchedule.at}{"\n"}{.status.consecutiveFailures}{"\n"}'Limiting concurrent jobs per repository
Section titled “Limiting concurrent jobs per repository”Nothing above bounds how many mover Jobs hit one repository at the same time. Twenty schedules that all fire at 02:00 produce twenty movers, all uploading to the same bucket, all competing for the same node pool. spec.concurrency.maxConcurrentJobs on the Repository, or ClusterRepository, is the ceiling:
spec: concurrency: maxConcurrentJobs: 3 # absent or 0 = unlimited (the default)Absent, or 0, means unlimited, which is the default and the behavior every previous release had. Setting it costs nothing until it binds.
This cap is the one hard limit on how much runs at once against a repository. It counts every mover Job, whether a schedule fired it or you started it with kubectl kopiur snapshot now or the console. A schedule’s concurrencyPolicy only keeps that schedule from overlapping runs of its own policies; it does not limit anything else.
One pool, not one per work kind
Section titled “One pool, not one per work kind”A repository has one budget, and backups, restores and the source side of both replication kinds all draw from it. That is deliberate. The shared resource is the repository’s backend and the bandwidth to it, so three separately “safe” limits, three backups plus three restores plus three replications, would still saturate it. One number, one answer to “how much can hit this backend at once”.
| Draws from the pool | Outside the pool |
|---|---|
Backup movers (Snapshot) |
Maintenance (quick and full) |
Restore movers (Restore, including the populator Job) |
SnapshotPolicy verification (quick and deep) |
RepositoryReplication reading this repository |
Snapshot pin/unpin |
SnapshotReplication reading this repository |
Batched snapshot deletions (snapdel-*) |
Repository bootstrap / catalog scan (<name>-discovery) |
|
kubectl kopiur browse session pods |
Each exclusion is a decision, not an oversight. Maintenance is the cure for an overloaded repository, so queuing it behind a full backup pool would make a struggling repository permanently unmaintainable. Deletions reduce load and have their own per-repository limit, maxConcurrentDeleteJobs (default one batch at a time). Bootstrap is what makes a repository Ready in the first place, so gating it on a pool that only fills once the repository is ready would deadlock a fresh one. A browse session is a human waiting at a terminal.
The cluster-wide backstop
Section titled “The cluster-wide backstop”A second, optional cap sits beneath the per-repository one. The Helm value maxConcurrentJobs, env KOPIUR_MAX_CONCURRENT_JOBS, default 0 meaning uncapped, bounds the same pool across every repository. Reach for it when the constraint is the node pool rather than any one backend. A run must satisfy both caps, and whichever it meets first holds it. See Installation → runtime tuning.
What a queued run looks like
Section titled “What a queued run looks like”A Snapshot that arrives at a full pool is parked before its Job is created. There is no mover pod, no credential projection, no source staging, and no side effects at all. It reports:
status.phase: PendingRepositorySlotAvailable=False, reasonWaitingForSlot, with a message naming the numbers:waiting for a mover slot on Repository billing/nas: 3/3 jobs running (global 8/12); restores are never held- a Normal event carrying the same message
$ kubectl get snapshot -n billingNAME PHASE ORIGIN SNAPSHOT AGEpostgres-data-20260901-02 Pending scheduled 40s
$ kubectl get snapshot postgres-data-20260901-02 -n billing \ -o jsonpath='{.status.conditions[?(@.type=="RepositorySlotAvailable")].message}{"\n"}'waiting for a mover slot on Repository billing/nas: 3/3 jobs running; restores are never heldTwo details in that message are worth knowing. The (global …) clause appears only when a cluster-wide cap is set; with no backstop configured it is omitted entirely rather than printed as noise. And when a run is held by the global cap alone, meaning the repository itself has no maxConcurrentJobs, the per-repository denominator renders as unlimited, giving 0/unlimited jobs running (global 12/12). Quoting a per-repository number the repository never set would be a lie about which setting to change.
The numbers in that message are EFFECTIVE counts. They are listed Jobs plus admissions already granted but not yet visible as Jobs, per the in-flight note just below. So a parked run can briefly report 1/1 jobs running a moment before kubectl get jobs shows the Job that number is counting.
A parked run re-checks its repository’s pool on its own timer, every 30 to 60 seconds, using a deterministic per-object offset so a queue of fifty runs doesn’t wake as one herd. Nothing pushes it awake sooner: a mover Job’s completion event routes only to the Snapshot that owns that Job, so it does not re-trigger the other runs queued behind it. Expect up to a minute of latency between a slot freeing and the next run taking it. When it does take it, the condition heals to True with reason SlotAcquired, showing holding a mover slot on Repository billing/nas.
Both replication kinds park and heal identically. If the heal write itself fails, it is retried while the run is in flight and, failing that, at the next run’s spawn, so a launched run never sits advertising a queue it already left.
Watching the queue
Section titled “Watching the queue”kopiur_snapshot_waiting_for_slot is a gauge with one series per queued Snapshot, labeled repository_kind, repository, namespace and name. sum by (repository) (kopiur_snapshot_waiting_for_slot) is the live queue depth per repository, and it drains to absence: the series disappears the moment the run is admitted or deleted, rather than lingering at zero. Terminal Snapshots are excluded by design, so a finished run can never hold the gauge on.
You don’t have to build the query yourself. The chart’s PrometheusRule, enabled by monitoring.prometheusRule.enabled, ships KopiurSnapshotWaitingForSlot, a warning that fires when one run has been queued for 30 minutes. It is per run, not per queue, because 30 minutes is a long time for a single backup to sit in line, and a summed depth cannot tell a stuck queue from a busy one with healthy turnover. The Grafana dashboard, enabled by monitoring.dashboards.enabled, plots the depth per repository in its Mover-slot queue depth panel, next to the “Snapshots gated” panel it is deliberately not merged with.
Jobs a queueing system has suspended don’t count
Section titled “Jobs a queueing system has suspended don’t count”A Job with spec.suspend: true, or a Suspended=True condition, has no pod and is doing no work, so it does not occupy a slot. This matters if you run Kueue or a similar admission controller, because those systems hold work by flipping exactly that field. Counting suspended Jobs would let a queueing system’s backlog fill Kopiur’s pool, and the two would deadlock each other, with Kopiur parking new runs behind Jobs Kueue is holding, forever.
How the cap interacts with concurrencyPolicy
Section titled “How the cap interacts with concurrencyPolicy”A SnapshotSchedule applies its concurrencyPolicy to runs of its own target policies: its own previous run, and, under Forbid and Replace, any other Running run of the same policy, such as a manual snapshot now. The cap applies to the repository. It is the only absolute limit: it counts every mover Job against the repository, manual and scheduled alike, whatever any schedule’s concurrencyPolicy says. They compose, and two combinations surprise people:
Forbid, the default, plus a full pool. The previous run is parked, not finished, soForbidskips the new slot, as designed. A parked child therefore holds the next slot, and the one after, until the queue drains. This is the cap doing its job: the alternative would be an unbounded pile ofPendingSnapshots for a repository that is already at capacity.Replaceplus a parked victim. Cancelling a queued run frees nothing, because it holds no slot, and the replacement would re-queue behind the same line. SoReplacedegrades toForbid-like waiting. The schedule says so:ReplacementHeld=Truewith a one-offWaitingForRepositorySlotevent. It clears itself once a slot frees. See whatReplacereally cancels.
Putting it together
Section titled “Putting it together”flowchart LR R[Repository<br/>where] --> BC[SnapshotPolicy<br/>recipe: what] BC --> BS[SnapshotSchedule<br/>cron: when] BS -->|creates| B[Snapshot<br/>one snapshot] kubectl[kubectl / automation] -->|creates| B B -->|finalizer + deletionPolicy| snap[(kopia snapshot)]
A SnapshotPolicy describes the work. A SnapshotSchedule, or you, turns it into Snapshot CRs. Each Snapshot owns one snapshot for its lifetime. Retention prunes old Snapshot CRs, and with deletionPolicy: Delete their snapshots too, keeping the repository in the GFS window.
See also
Section titled “See also”- Repositories & backends: where snapshots are stored.
- Restores: reading a snapshot back.
- Movers, RBAC & credentials: where backups actually run and what they need.
- Streamed command sources: the
streamsource form — capturepg_dumpall/mysqldumpstdout with nothing mounted, and itsstreamExecrestore. - Examples: 01 scheduled, 04 multi-PVC, 06 manual.