Restores
A Restore reads a snapshot back into a PersistentVolumeClaim (PVC). It answers three questions. Where to read from (source), where to write to (target), and how to write (options and policy). It also gives you the same mover settings a backup has: the UID/GID the mover runs as, the kopia cache, and a retry and deadline policy for the Job. Those are covered in Mover, cache & failure policy below.
A restore is “pick a row, write it somewhere”. In the common case there is no timestamp arithmetic to do. A Restore resolves its source once, the first time the operator reconciles it, and records the answer in its status. It never quietly switches to a different snapshot later.
Where to restore from — source
Section titled “Where to restore from — source”Set exactly one of three keys. The keys are externally tagged, so you write one key and its options underneath.
snapshotRef — restore a specific Snapshot (the default)
Section titled “snapshotRef — restore a specific Snapshot (the default)”Browse the catalog, pick a Snapshot CR, and name it. No timestamps involved. See example 03.
source: snapshotRef: name: postgres-data-20260524-021300 namespace: billing # optional; defaults to the Restore's namespaceTo find candidates:
$ kubectl get snapshots -n billing \ -l kopiur.home-operations.com/config=postgres-data \ --sort-by=.status.timing.startTimefromPolicy — resolve via a SnapshotPolicy’s identity
Section titled “fromPolicy — resolve via a SnapshotPolicy’s identity”Restore the latest snapshot for a SnapshotPolicy’s identity, or one at an offset or a point in time. This works even when no Snapshot CR exists yet. It is what powers deploy-or-restore (see below) and point-in-time rollback. See example 14 and scenario 07. The default is onMissingSnapshot: Continue.
source: fromPolicy: name: postgres-data namespace: billing # optional; defaults to the Restore's namespace offset: 0 # 0 = latest, 1 = previous, ... # asOf: 2026-05-01T00:00:00Z # or: newest snapshot at/before this instantasOf takes the newest snapshot at or before an RFC3339 instant. offset counts back from the latest. You normally set one or the other. asOf is the “roll back to a known-good time” knob; offset is “the previous one”. Setting both does work: asOf filters first, then offset counts back within what is left, giving you “the one before the last known-good”.
sourcePath — which volume of a multi-PVC policy to read
Section titled “sourcePath — which volume of a multi-PVC policy to read”A pvcSelector policy backs up several volumes. Each one is backed up under its own kopia source path: /pvc/<name> under sourcePathStrategy: PvcName, or /pvc/<namespace>/<name> under PvcNamespacedName. So a fromPolicy restore has to say which volume it wants. Usually you do not have to say it out loud, because Kopiur works it out from the PVC it is filling.
Kopiur picks the source path using the first rule that applies:
- Use
source.fromPolicy.sourcePathif you set it. Your value always wins. - Otherwise, if a plain
pvc:source in the policy names exactly this target (same name, same namespace), use that source’s path. An exact match beats both the derivation in rule 4 and the fallback in rule 3. - Otherwise, if the policy has no selector sources, use
sources[0]’s own path. That is what a restore always did. - Otherwise, if every selector source agrees on
(sourcePathStrategy, sourcePathOverride)and that override is unset, derive the path from the target PVC’s name. The restore runs the same code the backup side used, so the two strings cannot drift apart. - Otherwise, fail. The claim goes
Failedwith reasonSourcePathAmbiguous, and the message tells you to setsource.fromPolicy.sourcePath, for example/pvc/postgres-data.
source: fromPolicy: name: billing-app # Pin ONE member. Leave unset to derive the path from the PVC being # filled — which is what a multi-PVC populator wants. sourcePath: /pvc/postgres-dataidentity — a raw kopia identity
Section titled “identity — a raw kopia identity”Use this for snapshots written by a foreign kopia client, or ones that have aged out of the catalog. See example 13. You give the raw username@hostname:path. This mode requires an explicit spec.repository, because there is no Snapshot or SnapshotPolicy to infer it from.
repository: { kind: Repository, name: primary, namespace: backups }source: identity: username: postgres-data hostname: billing sourcePath: /data snapshotID: k1f1ec0a8 # pin an exact snapshot, or use asOf / offsetWhere to restore to — target
Section titled “Where to restore to — target”Set exactly one of four keys. Three write a PersistentVolumeClaim; the fourth, streamExec, writes no volume at all.
pvc — create a new PVC
Section titled “pvc — create a new PVC”The operator creates the PVC and restores into it. This is the best choice for verification restores, where you restore alongside the original and compare:
target: pvc: name: postgres-data-restored storageClassName: fast-ssd # optional; cluster default otherwise capacity: 100Gi # REQUIRED: the operator won't guess a size it creates accessModes: [ReadWriteOnce] # optional; defaults to [ReadWriteOnce]capacity is required, and the webhook enforces it. The operator creates this PVC, and a guessed default could be smaller than the data being restored. Size it at least as large as the source. The created PVC is deliberately not owned by the Restore, so deleting the Restore CR afterwards leaves the restored data in place.
pvcRef — write into an existing PVC
Section titled “pvcRef — write into an existing PVC”target: pvcRef: name: postgres-data # an existing PVC in this namespaceThe restore mover must mount this PVC to write into it. When a ReadWriteOnce target is held by a running pod, Kopiur puts the mover on that pod’s node automatically. A ReadWriteOncePod target cannot be co-mounted at all while a pod holds it, so scale the workload down first. See PVC access modes & RWOP.
populator: {} — passive populator mode
Section titled “populator: {} — passive populator mode”Set target.populator: {} and the Restore becomes a passive volume-populator source. It does not act on its own. Instead a PVC’s spec.dataSourceRef points at it, and the snapshot is restored as that PVC is provisioned. This is the GitOps deploy-or-restore pattern, covered in the next section.
target: populator: {} # explicit passive-populator modestreamExec — pipe one file into a command
Section titled “streamExec — pipe one file into a command”The mirror of a stream backup source: instead of writing files to a volume, the mover reads one virtual file out of the snapshot and pipes it into a command’s stdin in a running Pod. This is how a pg_dumpall artifact is replayed through psql. See Streamed command sources and example 46:
target: streamExec: fileName: postgres.sql # the fileName the backup's stream source used workloadExec: podSelector: matchLabels: { app: postgres } command: ["sh", "-ec", "psql -U postgres"]No PVC is created, mounted, or written, so options that describe writing files to a filesystem are inert here. The command runs in the workload’s container and uses the credentials already there. Exactly one Running pod must match podSelector, and the same namespace opt-in that gates a stream backup gates this too — it needs pods/exec in the workload namespace, and it fails closed, surfacing as MoverPermitted=False with reason StreamExecNotPermitted.
It is not available from kubectl kopiur restore — the flags map onto the three PVC targets — so write the Restore manifest. The CLI says as much if you reach for it.
One Restore serves every claim. Every PVC whose spec.dataSourceRef names this Restore claims it, not just the first one. Kopiur drives each claim on its own. Each claim gets its own prime PVC, its own mover Job, its own per-PVC source path, its own waitTimeout window, and its own record under status.claims.<pvc>:
$ kubectl get restore billing-app-restore -n billing -o jsonpath='{.status.claims}' | jq{ "postgres-data": { "phase": "Populated", "sourcePath": "/pvc/postgres-data", "reason": "RestoreSucceeded" }, "postgres-uploads": { "phase": "Populating", "sourcePath": "/pvc/postgres-uploads", "reason": "PopulatingPrimePvc" }}The Restore’s own PHASE summarizes all of its claims. It reads Completed once every claim has settled, Failed if any claim failed, and Restoring or Pending while work is still outstanding. Each claim’s own phase is one of Pending, Populating, Rebinding, Populated, AlreadyBound, or Failed.
With exactly one claim, the ordinary single-PVC app, the Restore is that claim. Kopiur mirrors the claim’s state onto the top-level status: status.resolved (the recorded decision), status.target, and the Ready and Resolved conditions all carry that claim’s values unchanged, exactly as they did before one Restore could serve several claims. With several claims there is no single answer. The top-level resolved and target are cleared, the conditions carry the summary across all claims, and status.claims.<pvc> is the record to read for each volume.
How to write — options and policy
Section titled “How to write — options and policy”options: enableFileDeletion: false # default: additive restore (don't delete extra files in the target) ignorePermissionErrors: true # default true writeFilesAtomically: true # default true parallel: 4 # restore parallelism; kopia default 8 skipTimes: true # skip restoring file modification times overwriteFiles: true # overwrite existing files in the targetpolicy: onMissingSnapshot: Fail # see table below waitTimeout: 5m # how long to wait for the source snapshot to appearoptions also exposes the rest of kopia snapshot restore’s own tuning flags directly, one field per flag: writeSparseFiles, skipOwners, skipPermissions, skipTimes, overwriteFiles, overwriteDirectories, overwriteSymlinks, ignoreErrors, and skipExisting. All of those are tri-state: true, false, or absent, where absent means “let kopia decide”. parallel takes a count. See the field reference for kopia’s own default for each flag.
onMissingSnapshot — fail-closed vs proceed
Section titled “onMissingSnapshot — fail-closed vs proceed”| Value | Behavior | Default for |
|---|---|---|
Fail |
No matching snapshot ⇒ the restore fails. | snapshotRef / identity (explicit sources). |
Continue |
No matching snapshot ⇒ provision a fresh, empty volume and complete. | fromPolicy. |
The defaults are the point. An explicit restore that finds nothing is an error you want surfaced. A deploy-or-restore that finds nothing should let the app start with a fresh volume.
On Continue with no snapshot, Kopiur actually provisions the empty volume. It does not just mark the Restore complete. For target.populator: {} it provisions an empty prime PVC and rebinds it to the claiming PVC, so a workload pod can bind and start. For target.pvc it creates the empty PVC. The “no snapshot, so empty” decision is written to status.resolved as resolution: NoSnapshot once and never re-resolved, so a snapshot that appears later can never quietly restore over a volume the app is already using. Re-create the Restore if you want to pick up a new snapshot. The Restore reports Completed with Resolved=True reason=NoSnapshotContinue.
The empty-volume path applies to fromPolicy and identity sources on every backend. The restore Job resolves the source in place, so an object-store fromPolicy with no snapshot comes up empty under Continue just like a filesystem one.
waitTimeout — wait before giving up
Section titled “waitTimeout — wait before giving up”waitTimeout takes a Go-style duration such as 5m. It opens a grace window during which “no matching snapshot yet” means wait and re-check instead of giving up. onMissingSnapshot applies only once the window closes. Use it when the Restore may be applied before the thing that produces its snapshot: a schedule about to fire, a GitOps apply ordering, or a populator claim racing the first backup.
The window opens when the restore can first actually proceed, not when you applied it. Kopiur stamps that instant into status.waitStartedAt and measures waitTimeout from there. Two things have to be true first. The restore’s repository has reached Ready, and, for target.populator, a PVC already claims the Restore through dataSourceRef. Until then the clock has not started.
The window also does not open while the objects Kopiur reads the repository from are missing. If the referenced Repository object, or the SnapshotPolicy a fromPolicy source names, does not exist yet, the restore parks in Pending with ReferentAvailable=False reason=RestoreReferentMissing and status.waitStartedAt stays unstamped. It re-checks every 15 s, so it un-parks on its own once you apply the missing object. Two cases deliberately do open the window without that check. The first is a snapshotRef whose Snapshot row does not exist yet: that row is precisely what the window is waiting for, and onMissingSnapshot has to be able to fire for a reference that never appears. The second is a restore whose mover Job has already launched, which keeps the deadline it was dispatched with.
Where the waiting happens depends on the source. A snapshotRef waits for the referenced Snapshot CR to gain an id, and it re-checks in the controller roughly every 15 s, surfacing Resolved=False reason=WaitingForSnapshot on the conditions. A fromPolicy or identity source re-lists the repository inside the restore Job, the same mover run that does the restore, so it works on every backend. During that window the Restore shows Restoring rather than a per-poll condition. Either way the window is an absolute deadline computed from status.waitStartedAt, so it is bounded even across controller restarts or Job pod retries. Because the wait runs inside the Job for fromPolicy and identity, waitTimeout must be shorter than the Job’s failurePolicy.activeDeadlineSeconds. The admission webhook rejects a Restore that sets both with waitTimeout greater than or equal to the deadline.
When a populator’s claiming PVC is deleted and re-created, the restore resolves again, and that clears status.waitStartedAt. The re-created claim gets the full window again rather than inheriting the previous claim’s spent one.
Mover, cache & failure policy
Section titled “Mover, cache & failure policy”A restore writes data into a PVC, so the mover doing the writing has the same concerns a backup’s mover does. Restore.spec.mover is the same MoverSpec a SnapshotPolicy exposes, and Restore.spec.failurePolicy mirrors Snapshot.spec.failurePolicy. See the full manifest in example 12.
A Restore is the invocation, so this block is already per-run — there is no recipe to override. The backup side reaches the same place in two steps: the recipe’s SnapshotPolicy.spec.mover, overridden for a single ad-hoc run by Snapshot.spec.mover.
spec: mover: securityContext: { runAsUser: 1000, runAsGroup: 1000, ... } # CONTAINER: own the restored files podSecurityContext: { fsGroup: 1000 } # POD: make a fresh volume writable # inheritSecurityContextFrom: { workloadSelector: { podSelector: {...} } } # ...or copy from a live pod (restore: workloadSelector, not pvcConsumer) # inheritSecurityContextFrom: { snapshot: {} } # ...or replay the identity RECORDED on the backup (no live pod needed) cache: { capacity: 16Gi, mode: Persistent, contentCacheSizeMb: 10000 } failurePolicy: backoffLimit: 4 activeDeadlineSeconds: 7200 # cap a RUNNING restore (default 48h backstop) podStartupDeadlineSeconds: 300 # fail a restore mover that can't START in 5m (default 300)mover.securityContextsets the UID/GID the restore mover’s container runs as, which is the identity that will own the restored files. Without it the mover runs as the hardened default, UID 65532, which may write files the app cannot read. This is the fix for “the restore mover had no UID control”.mover.podSecurityContext.fsGroupsets a pod-levelfsGroupthat makes a freshly-provisioned target volume group-writable. That lets an unprivilegedrunAsUser: 1000mover populate the volume on restore, instead of needing a root mover just to write a new volume. This is the headline case for restoring into a brand-new PVC as non-root. See Security context → fsGroup.mover.inheritSecurityContextFromcopies both the containersecurityContextand the pod-levelsecurityContextfrom somewhere authoritative, instead of you hard-coding them. That means the restore mover gets the app’s UID and itsfsGroup. On a Restore, two forms work.workloadSelector: { podSelector, container? }names a live pod that will read the restored data.snapshot: {}replays the identity recorded on the backup itself (Snapshot.status.recorded), which needs no live pod; see the re-bootstrap section below. ThepvcConsumerform is backup-only. It derives the workload from a backup source PVC, which a restore does not have, because the target’s consumer may not exist yet. The webhook therefore rejectspvcConsumeron aRestore. Inheriting combines withsecurityContextandpodSecurityContext: those are the higher merge layer, so a field you set explicitly overrides the inherited one, and they stand in alone when no workload pod resolves. In that case the restore proceeds on your fields and reportsSecurityContextInherited=Falsewith reasonInheritFallback, because the restored files will then be owned as your context says rather than as the workload you named. The conditionRestoreSecurityContextCompatiblereports, positively only, when the future consumer will be able to read what the mover writes. See Security context → Inherit it from the workload and example 18.mover.cachesizes the kopia cache for a large restore.mode: Ephemeral, the default, gives a fresh per-run volume sized bycapacity, or anemptyDirwhencapacityis unset.mode: Persistentkeeps a controller-owned cache PVC and reuses it across runs for a warm cache.contentCacheSizeMbandmetadataCacheSizeMbpass kopia’s--content/metadata-cache-size-mbbudgets. A repository’smoverDefaults.cacheis inherited, andmover.cacheoverlays it.failurePolicysets the restore Job’sbackoffLimit,activeDeadlineSeconds, andpodStartupDeadlineSeconds. Leaving it absent uses the defaults: 2 retries, a 48hactiveDeadlineSecondsbackstop so a running Job cannot linger forever, and a 5-minutepodStartupDeadlineSecondsso a restore mover that cannot start fails fast withMoverPodWedgedinstead of hanging. A mover fails to start on a bad image, an unschedulable pod, or an impossiblesecurityContext. The two deadlines are explained in Backups →failurePolicy.
Deploy-or-restore (GitOps)
Section titled “Deploy-or-restore (GitOps)”This is the headline pattern: commit one bundle and apply it to any cluster. On a fresh cluster pointed at an existing repository, the PVC restores the latest snapshot before the app starts. On a brand-new repository, the PVC comes up empty and is backed up going forward. You never write an “is this a new install or a recovery?” branch.
The mechanism is a passive Restore using source.fromPolicy, target.populator: {}, and onMissingSnapshot: Continue, consumed by a PVC’s dataSourceRef as a volume populator. The full manifest is example 05. The same Restore keeps serving claims for its whole life. Tear the app’s PVC down and stand it back up, whether that is a kubectl delete and re-apply, a namespace rebuild, or a migration, and the new PVC is populated again from the recorded snapshot. Nothing about the Restore changes. See the reusability note above.
Deploy-or-restore for a multi-PVC app
Section titled “Deploy-or-restore for a multi-PVC app”An app whose data lives on several PVCs needs exactly the same three objects: a pvcSelector policy, a schedule, and one Restore. One populator Restore serves every claim. Each PVC below reads its own volume’s history, derived from its own name:
---apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: billing-app namespace: billingspec: repository: kind: Repository name: nas-primary sources: # Every PVC labelled `backup: billing` is captured. Each becomes its own # Snapshot CR, its own kopia snapshot and its own retention bucket. - pvcSelector: labelSelector: matchLabels: backup: billing # `/pvc/<name>` — this is what makes a per-PVC RESTORE path derivable. # `PvcNamespacedName` (`/pvc/<namespace>/<name>`) works the same way. sourcePathStrategy: PvcName # Capture each PVC independently: works on any snapshot-capable driver and # with `copyMethod: Direct`. Use `VolumeGroupSnapshot` (the default) when the # volumes must agree with each other — the restore side is identical. groupBy: None---apiVersion: kopiur.home-operations.com/v1alpha1kind: Restoremetadata: name: billing-app-restore namespace: billingspec: source: fromPolicy: name: billing-app offset: 0 # 0 = latest, 1 = previous, ... # sourcePath: /pvc/postgres-data # Pins EVERY claim of this Restore to ONE member path. Leave it unset # for the per-PVC derivation above (which is what a multi-PVC app # wants); set it only to force one member — for a cross-namespace # `target.pvcRef`, or when the policy's selectors are ambiguous. # PASSIVE populator: this Restore does nothing on its own. Every PVC below # claims it, and each is populated as it is provisioned. target: populator: {} policy: onMissingSnapshot: Continue # fresh repository ⇒ empty volumes, not a failure waitTimeout: 5m # per claim: a PVC applied later gets its own full window---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: postgres-data namespace: billing # The SAME label the policy selects on, so this volume keeps being backed up # once it is restored. labels: backup: billingspec: storageClassName: fast-ssd accessModes: - ReadWriteOnce resources: requests: storage: 100Gi # Claims the Restore above. Its data comes from /pvc/postgres-data — derived # from THIS PVC's name, not from whichever member happens to be newest. dataSourceRef: apiGroup: kopiur.home-operations.com kind: Restore name: billing-app-restore---apiVersion: v1kind: PersistentVolumeClaimmetadata: name: postgres-uploads namespace: billing labels: backup: billingspec: storageClassName: fast-ssd accessModes: - ReadWriteOnce resources: requests: storage: 500Gi # The SAME Restore, a second claim. Its data comes from /pvc/postgres-uploads. dataSourceRef: apiGroup: kopiur.home-operations.com kind: Restore name: billing-app-restoreThe full manifest, with the schedule and the commentary on the failure modes, is deploy/examples/44-multi-pvc-deploy-or-restore.yaml. Watch it land with kubectl get restore <name> -o jsonpath='{.status.claims}'. The cost is N pooled movers.
Restoring a snapshot Kopiur didn’t create
Section titled “Restoring a snapshot Kopiur didn’t create”Snapshots written by a foreign kopia client, or ones predating your install, show up as discovered Snapshot CRs in the repository’s namespace. They carry origin=discovered and a forced deletionPolicy: Retain. There are two ways to restore them, both in example 07:
- (A) reference the discovered
SnapshotCR withsource.snapshotRef, the same as any other backup; or - (B) use
source.identitywith the raw kopia identity, which requiresspec.repository, for snapshots that aged out of the catalog.
$ kubectl get snapshots -n backups -l kopiur.home-operations.com/origin=discoveredDeclarative re-bootstrap — restore as the recorded identity
Section titled “Declarative re-bootstrap — restore as the recorded identity”After a full cluster loss, the workload whose UID a restore should run as does not exist yet, so there is no pod to inherit from. mover.inheritSecurityContextFrom: { snapshot: {} } closes that gap. The restore mover runs as the identity recorded on the backup, read from Snapshot.status.recorded, which is decoded from the kopiur-meta kopia tag. No live pod is involved. The whole recovery is then one declarative apply:
- GitOps applies Repository + SnapshotPolicy + Restore to the fresh cluster, with
source.fromPolicyandsnapshot: {}. - The repository connects. The catalog scan materializes
discoveredSnapshot CRs from the repository, decoding each snapshot’s recorded identity intostatus.recorded. - Until a matching row exists, the Restore holds with
SecurityContextInherited=Falseand reasonMissingRecordedIdentity. A Warning Event says why, and it re-checks every few minutes. This is the expected intermediate state, not an error to fix. - The moment the scan lands, the Restore resolves the recorded identity, runs the mover as it, and completes.
You hand-author no snapshot names anywhere. fromPolicy re-resolves the kopia identity from the live SnapshotPolicy, and the controller picks the matching Snapshot CR from the catalog, honoring asOf and offset. source.identity works the same way, including a pinned snapshotID. source.snapshotRef also supports snapshot: {}: it reads that CR’s status.recorded directly. Pair this with target: { populator: {} } for deploy-or-restore. Because snapshot needs no live pod, it is the one inherit mode allowed with a populator target.
# Example 37 — Restore as the identity RECORDED on the backup## Every backup records the uid/gid/fsGroup its mover actually ran as on the kopia# snapshot itself (the `kopiur-meta` tag), surfaced as `Snapshot.status.recorded`.# `inheritSecurityContextFrom: { snapshot: {} }` makes a Restore's mover run as# that recorded identity — no live workload pod needed, so it works when the app# is not deployed yet and after a full cluster re-bootstrap.## Works with EVERY source:# - `snapshotRef` reads the referenced Snapshot CR's status.recorded.# - `fromPolicy`/`identity` search the CR catalog by kopia identity, honoring# asOf/offset/snapshotID — this is what makes the GitOps re-bootstrap# declarative (second Restore below).## Holds and warnings (SecurityContextInherited condition):# - `MissingRecordedIdentity` (False): the Snapshot CR is missing, carries no# status.recorded (pre-feature or foreign backup), or the catalog scan has# not materialized a matching row yet. The Restore holds and re-checks every# few minutes; the scan backfills recorded metadata automatically.# - `RecordedPinnedNoUid` (False): the backup recorded no pinned uid (its# mover's identity was image-determined) — inheriting contributes nothing.# - `RecordedApplied` (True): names the Snapshot, the recorded uid, and its# provenance (`inherited`/`explicit`/`defaults`).## TRUST BOUNDARY: recorded metadata is repository data — anyone with repository# write credentials can forge it (including uid 0). A recorded ROOT identity is# gated on the namespace privileged-movers opt-in exactly like live-pod inherit,# and the condition/Events always name the recorded uid + snapshot.## Field shapes verified against crates/api:# spec.mover.inheritSecurityContextFrom : exactly one of# { workloadSelector: {...} } | { pvcConsumer: {...} } | { snapshot: {} }# `snapshot` is RESTORE-only (webhook-rejected on SnapshotPolicy/Maintenance).## YAML FOOTGUN: write `snapshot: {}` — a bare `snapshot:` is YAML null and is# rejected at admission (the variant is an empty sub-object, not a null).---# Direct: restore a specific Snapshot as the identity it recorded.apiVersion: kopiur.home-operations.com/v1alpha1kind: Restoremetadata: name: postgres-restore-as-recorded namespace: billingspec: source: snapshotRef: name: postgres-data-20260523-021300 target: pvc: name: postgres-data-restored capacity: 100Gi mover: inheritSecurityContextFrom: snapshot: {} # the empty sub-object is required (`snapshot:` alone is null)---# Declarative re-bootstrap (GitOps): apply Repository + SnapshotPolicy + this# Restore together on a fresh cluster. The Restore holds on# `MissingRecordedIdentity` until the catalog scan materializes a discovered# Snapshot CR carrying `status.recorded` for the policy's identity, then# restores the latest backup as the identity it recorded. Pair with# `target: { populator: {} }` for deploy-or-restore (`snapshot` needs no live# pod, so it is the one inherit mode allowed with a populator target).apiVersion: kopiur.home-operations.com/v1alpha1kind: Restoremetadata: name: postgres-rebootstrap namespace: billingspec: source: fromPolicy: name: postgres-data # identity re-resolved from the live SnapshotPolicy target: pvcRef: name: postgres-data # the PVC your app deployment already claims mover: inheritSecurityContextFrom: snapshot: {}What the mover runs as, how far you can trust recorded metadata (it is repository data and can be forged, so a recorded root identity stays gated on the namespace opt-in), and the full SecurityContextInherited reason table all live in Security context → snapshot.
Watching a restore
Section titled “Watching a restore”$ kubectl get restore -n billing -wNAME PHASE AGEpostgres-verify Resolving 2spostgres-verify Restoring 9spostgres-verify Completed 41sThe phases run Pending → Resolving (picking the source snapshot) → Restoring (mover writing data) → Completed or Failed. Live byte and file progress is in status.progress. The resolved snapshot and target PVC are in status.resolved and status.target. If it will not progress, kubectl describe restore <name> shows the reason on the conditions and as an Event. See Troubleshooting.
Every phase write also carries the kstatus conditions. Completed gives Ready=True. Failed gives Stalled=True, because a Restore is one-shot: fix the cause and create a new Restore. Anything in flight gives Reconciling=True. So kubectl wait --for=condition=Ready restore/<name> and Flux or Argo health checks gate on a restore the same way they do on every other kopiur kind. The domain conditions Resolved, MoverPermitted, CredentialsAvailable and AwaitingClaim survive phase transitions alongside them.
A restore is never held behind a concurrency cap
Section titled “A restore is never held behind a concurrency cap”A Repository’s concurrency.maxConcurrentJobs bounds how many mover Jobs run against it at once, and backups queue behind it. Restores never do. A restore is a recovery in progress, and holding one behind a queue of routine nightly backups is exactly backwards. So a restore is admitted at or over the cap, and it never carries a RepositorySlotAvailable condition at all, either way.
It does still count, and it counts from the moment the operator decides to run it, not from the moment its Job shows up in kubectl get jobs. A restore occupies a slot like any other pooled Job, so it displaces backups rather than adding to them, which is what a cap is actually being asked for. If you set maxConcurrentJobs: 3 and start three large restores, backups against that repository queue until the restores finish.
The “from the moment of the decision” part matters. A restore that only became countable once its Job existed would be invisible for the short window in which the operator is still resolving its source, staging its target PVC and projecting credentials. A backup reconciling in that window would read spare capacity and start a second mover beside the recovery. A restore reserves its slot up front, so the cap holds even when a restore and a backup arrive at the same instant.
N claims means N pooled movers. A populator Restore over a multi-PVC app runs one mover per claiming PVC, and each takes its own slot on the same terms as above: admitted at and over the cap, counted from the decision. So a ten-volume recovery displaces routine backups against that repository until it finishes. There is no per-Restore cap, because a recovery is not the thing to throttle, but size maxConcurrentJobs knowing that one Restore can be worth ten movers.
None of this needs configuration. It is fixed behavior of the pool. See Backups → limiting concurrent jobs per repository for the whole picture.
Credentials in a fresh namespace — credentialProjection
Section titled “Credentials in a fresh namespace — credentialProjection”A restore mover loads the repository credentials with envFrom from a Secret in its own namespace. Restoring into a namespace that has never run a backup, such as a disaster recovery or a clone target, will not have one. Set credentialProjection.enabled: true and the operator copies the referenced repository’s Secret into the mover’s namespace for the run. The copy is owned by the Restore and garbage-collected with it. See example 17:
spec: repository: { kind: ClusterRepository, name: platform-shared } credentialProjection: enabled: true # off by default; needs Helm features.credentialProjection.enabledIt is off by default, because copying Secrets across namespaces is opt-in, and it needs the operator’s Secret-projection RBAC through the Helm flag features.credentialProjection.enabled. The alternative is placing the Secret in the namespace yourself. See Movers → credential projection.
Field reference — every value, and when to change it
Section titled “Field reference — every value, and when to change it”The full Restore surface, with the examples that exercise each field. source is the only required field.
| Field | What it does | When to set it |
|---|---|---|
repository |
The repository to read from ({ kind, name, namespace? }). Inferred from source for snapshotRef/fromPolicy; required for identity. |
Cross-namespace / cluster restores, or any identity source. (13, 16) |
source.snapshotRef |
Restore a specific Snapshot CR ({ name, namespace? }). |
The common case: you picked a row from the catalog. (03, 16) |
source.fromPolicy |
Resolve via a SnapshotPolicy’s identity ({ name, namespace?, asOf?, offset? }). |
No Snapshot CR (deploy-or-restore), or point-in-time (asOf) / positional (offset) recovery. (05, 14) |
source.identity |
Raw kopia identity ({ username, hostname, sourcePath?, snapshotID?, asOf?, offset? }). |
Foreign / aged-out snapshots; needs repository. (13) |
target.pvc |
Create a new PVC and restore into it ({ name, storageClassName?, capacity?, accessModes? }). |
The safe default: restore beside the original, verify, then cut over. (03) |
target.pvcRef |
Restore into an existing PVC ({ name }). |
In-place restore (scale the app down first). (15) |
target.populator |
Explicit passive volume-populator source (populator: {}). |
GitOps deploy-or-restore via a PVC dataSourceRef. (05) |
target.streamExec |
Pipe ONE virtual file into a command’s stdin in a running Pod ({ fileName, workloadExec }). Writes no PVC. |
Replaying a logical dump (psql < pg_dumpall). (stream sources) |
options.enableFileDeletion |
Delete target files not in the snapshot (exact mirror); wired to kopia’s --delete-extra. Default false (additive). |
A faithful in-place restore. Destructive, so use it deliberately. (15) |
options.ignorePermissionErrors |
Complete and report permission problems vs. fail hard. Default true. |
false to fail-closed when exact permissions matter. |
options.writeFilesAtomically |
Write via a temp file + rename. Default true. |
Rarely changed. |
options.parallel |
Restore parallelism (--parallel). Kopia default 8. |
Large restores on fast storage/network. |
options.writeSparseFiles / skipOwners / skipPermissions / skipTimes |
Tri-state passthroughs to kopia’s --[no-]write-sparse-files / --[no-]skip-owners / --[no-]skip-permissions / --[no-]skip-times. Absent ⇒ kopia’s own default. |
Sparse-file-heavy targets; cross-platform restores where owners/permissions/times don’t translate. |
options.overwriteFiles / overwriteDirectories / overwriteSymlinks |
Tri-state passthroughs to kopia’s --[no-]overwrite-* (kopia default true for all three). |
false to refuse clobbering an existing target. |
options.ignoreErrors / skipExisting |
Tri-state passthroughs to kopia’s --[no-]ignore-errors / --[no-]skip-existing. Kopia default false for both. |
Best-effort restores; resuming a partially-written target. |
policy.onMissingSnapshot |
Fail (explicit sources) vs Continue (fromPolicy default). |
Fail for deliberate recoveries; Continue for deploy-or-restore. |
policy.waitTimeout |
How long to wait for the source snapshot to appear. | Sources that may lag behind the Restore being applied. |
mover.securityContext / podSecurityContext |
Container UID/GID, and the pod-level fsGroup that makes a fresh target volume writable. |
Own restored files as the app’s UID; populate a fresh PVC as non-root (fsGroup). See Mover, cache & failure policy. (12) |
mover.cache / resources / inheritSecurityContextFrom |
Cache sizing/mode, mover resources, inherit-from-pod. | Large-restore cache, resource limits, run-as-the-app. (12) |
failurePolicy |
Restore Job backoffLimit / activeDeadlineSeconds. |
Retry/deadline control for big or flaky restores. (12) |
credentialProjection |
Project the repo Secret into the mover’s namespace. | Restoring into a fresh namespace from a shared repo. (17) |
See also
Section titled “See also”- Backups & schedules: producing the snapshots you restore.
- Repositories & backends: where the snapshots live.
- Permissions: choosing the mover’s UID/GID and the privileged-movers opt-in (applies to restores too).
- Streamed command sources: the
streamExectarget and thestreambackup source it reads back. - Scenarios: 02 recover lost data, 07 point-in-time rollback, 08 clone to another namespace, 10 DR from a replicated repository.
- Examples: 03 by Snapshot, 05 deploy-or-restore, 07 discovered, 12 mover/cache/failure policy, 13 by identity, 14 point-in-time, 15 in-place mirror, 16 cross-namespace, 17 shared-repo projection.