Skip to content

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.

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 namespace

To find candidates:

Terminal window
$ kubectl get snapshots -n billing \
-l kopiur.home-operations.com/config=postgres-data \
--sort-by=.status.timing.startTime

fromPolicy — 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 instant

asOf 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:

  1. Use source.fromPolicy.sourcePath if you set it. Your value always wins.
  2. 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.
  3. Otherwise, if the policy has no selector sources, use sources[0]’s own path. That is what a restore always did.
  4. 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.
  5. Otherwise, fail. The claim goes Failed with reason SourcePathAmbiguous, and the message tells you to set source.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-data

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 / offset

Set exactly one of four keys. Three write a PersistentVolumeClaim; the fourth, streamExec, writes no volume at all.

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.

target:
pvcRef:
name: postgres-data # an existing PVC in this namespace

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

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 mode

streamExec — 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>:

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

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 target
policy:
onMissingSnapshot: Fail # see table below
waitTimeout: 5m # how long to wait for the source snapshot to appear

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

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.securityContext sets 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.fsGroup sets a pod-level fsGroup that makes a freshly-provisioned target volume group-writable. That lets an unprivileged runAsUser: 1000 mover 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.inheritSecurityContextFrom copies both the container securityContext and the pod-level securityContext from somewhere authoritative, instead of you hard-coding them. That means the restore mover gets the app’s UID and its fsGroup. 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. The pvcConsumer form 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 rejects pvcConsumer on a Restore. Inheriting combines with securityContext and podSecurityContext: 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 reports SecurityContextInherited=False with reason InheritFallback, because the restored files will then be owned as your context says rather than as the workload you named. The condition RestoreSecurityContextCompatible reports, 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.cache sizes the kopia cache for a large restore. mode: Ephemeral, the default, gives a fresh per-run volume sized by capacity, or an emptyDir when capacity is unset. mode: Persistent keeps a controller-owned cache PVC and reuses it across runs for a warm cache. contentCacheSizeMb and metadataCacheSizeMb pass kopia’s --content/metadata-cache-size-mb budgets. A repository’s moverDefaults.cache is inherited, and mover.cache overlays it.
  • failurePolicy sets the restore Job’s backoffLimit, activeDeadlineSeconds, and podStartupDeadlineSeconds. Leaving it absent uses the defaults: 2 retries, a 48h activeDeadlineSeconds backstop so a running Job cannot linger forever, and a 5-minute podStartupDeadlineSeconds so a restore mover that cannot start fails fast with MoverPodWedged instead of hanging. A mover fails to start on a bad image, an unschedulable pod, or an impossible securityContext. The two deadlines are explained in Backups → failurePolicy.

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.

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/v1alpha1
kind: SnapshotPolicy
metadata:
name: billing-app
namespace: billing
spec:
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/v1alpha1
kind: Restore
metadata:
name: billing-app-restore
namespace: billing
spec:
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: v1
kind: PersistentVolumeClaim
metadata:
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: billing
spec:
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: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-uploads
namespace: billing
labels:
backup: billing
spec:
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-restore

The 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 Snapshot CR with source.snapshotRef, the same as any other backup; or
  • (B) use source.identity with the raw kopia identity, which requires spec.repository, for snapshots that aged out of the catalog.
Terminal window
$ kubectl get snapshots -n backups -l kopiur.home-operations.com/origin=discovered

Declarative 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:

  1. GitOps applies Repository + SnapshotPolicy + Restore to the fresh cluster, with source.fromPolicy and snapshot: {}.
  2. The repository connects. The catalog scan materializes discovered Snapshot CRs from the repository, decoding each snapshot’s recorded identity into status.recorded.
  3. Until a matching row exists, the Restore holds with SecurityContextInherited=False and reason MissingRecordedIdentity. A Warning Event says why, and it re-checks every few minutes. This is the expected intermediate state, not an error to fix.
  4. 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.

deploy/examples/37-restore-recorded-identity.yaml
# 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/v1alpha1
kind: Restore
metadata:
name: postgres-restore-as-recorded
namespace: billing
spec:
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/v1alpha1
kind: Restore
metadata:
name: postgres-rebootstrap
namespace: billing
spec:
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.

Terminal window
$ kubectl get restore -n billing -w
NAME PHASE AGE
postgres-verify Resolving 2s
postgres-verify Restoring 9s
postgres-verify Completed 41s

The 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.enabled

It 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)