The mover security context
Every backup and restore in Kopiur runs in a short-lived mover pod, and that pod’s security context decides which files it can read and write. This page explains what the security context is, which fields matter, the three ways to set it, how to work out the right values, and how to handle the awkward cases: mixed ownership, ReadWriteMany volumes, preserving ownership on restore, and restricted namespaces.
What “security context” means here
Section titled “What “security context” means here”A Kubernetes SecurityContext is the block that sets a container’s Linux identity and privileges: the UID and GID it runs as, whether it may escalate, which capabilities it holds, its seccomp profile, and so on. Kopiur exposes the standard, unmodified core/v1 SecurityContext on every kind that runs a mover:
| Kind | Field |
|---|---|
SnapshotPolicy |
spec.mover.securityContext |
Restore |
spec.mover.securityContext |
Maintenance |
spec.mover.securityContext |
spec.mover.securityContext is applied at the container level, on the mover container. Pod-level settings, most importantly fsGroup, go in a separate sibling field:
| Kind | Container-level | Pod-level |
|---|---|---|
SnapshotPolicy |
spec.mover.securityContext |
spec.mover.podSecurityContext |
Restore |
spec.mover.securityContext |
spec.mover.podSecurityContext |
Maintenance |
spec.mover.securityContext |
spec.mover.podSecurityContext |
The default (hardened) context
Section titled “The default (hardened) context”If you set nothing, the mover runs unprivileged as the mover image’s user, UID 65532 (distroless nonroot), with a hardened context:
securityContext: runAsNonRoot: true allowPrivilegeEscalation: false readOnlyRootFilesystem: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefaultThis default satisfies the Pod Security Admission restricted profile, so it runs in locked-down namespaces out of the box. What it can read is limited: files that are world-readable, or owned by UID 65532. Most app images run as some other UID such as 1000, 1001 or 999, and write files 0600 or 0640. So the default mover gets permission denied on real app data. Whenever the source isn’t world-readable, you will set the context to match the data. Read on.
At the pod level, every mover also gets a hardened default podSecurityContext. That covers backup, restore, maintenance, and the bootstrap connect/create Job:
podSecurityContext: fsGroup: 65532 # the mover image's GID — makes mounted volumes group-writable by the mover fsGroupChangePolicy: OnRootMismatch # only chown when the volume root doesn't already matchThe fsGroup matches the mover image’s GID so the operator-managed kopia cache is writable out of the box. Without it, a PVC-backed cache under moverDefaults.cache.mode: Ephemeral or Persistent is created root:root, and the unprivileged mover fails with mkdir /var/cache/kopia/logs: permission denied. OnRootMismatch keeps it cheap: a volume already owned by the group isn’t re-chowned on every run. This is the lowest merge layer, so moverDefaults.podSecurityContext and a recipe’s mover.podSecurityContext override it field by field. For example, set fsGroup to your app’s GID for a restore, and the rest of the hardened defaults stay put.
How to decide what to set
Section titled “How to decide what to set”The whole problem reduces to one number, sometimes two: the numeric UID and GID that own the data.
- Find the owner. Read it from the running app with
kubectl exec … -- idorls -ln. If nothing mounts the PVC, use a throwaway inspection pod. The step-by-step recipe lives in the Permissions guide → Find the UID/GID. It includes the lowest-common-denominator rule: if any file you need is0600owned by1000, the mover must be UID1000; if everything is at least group-readable and shares a GID, matching the GID is enough. - Decide backup vs restore intent:
- Backup: pick a UID/GID that can read the source.
- Restore: pick the UID/GID that should own the restored files, so the app can read them afterward. To reproduce the original ownership exactly, see Preserving ownership on restore.
- Choose how to express it: one of the three approaches below.
Three ways to set the context
Section titled “Three ways to set the context”1. Set it explicitly
Section titled “1. Set it explicitly”The most direct approach: hard-code the UID and GID under spec.mover.securityContext, keeping the rest of the hardened context.
spec: mover: securityContext: runAsUser: 1000 # the UID that owns the data runAsGroup: 1000 # the GID that owns the data runAsNonRoot: true # keep the unprivileged guarantee allowPrivilegeEscalation: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefaultFull, apply-ready example:
# Example 09 — Mover UID/GID & permissions## The mover Job is what reads your PVC. It must run as a UID/GID that can READ the# data. By default movers run unprivileged as the image user (UID 65532) with a# hardened securityContext (runAsNonRoot, drop ALL caps, seccomp RuntimeDefault).## WHERE to set the UID/GID — three layers, lowest first:# * `Repository.spec.moverDefaults` — the SINGLE place to set PUID/PGID for the# whole repository. Every mover it spawns (bootstrap, backup, restore,# maintenance) inherits these. This is the new home of the old KOPIUR_PUID /# KOPIUR_PGID env-var story: set runAsUser/runAsGroup on the container context# and fsGroup on the pod context ONCE, here.# * `SnapshotPolicy.spec.mover` / `Restore.spec.mover` — a per-recipe override.# It MERGES field-wise over moverDefaults and the hardened base (it can only# tighten — a partial override never drops drop:[ALL]/seccomp).# * `Snapshot.spec.mover` — a PER-RUN override for one ad-hoc snapshot, merged# over the policy's. Use it so a one-shot debug run does not require editing# the shared, GitOps-managed SnapshotPolicy every scheduled run also uses.## This example sets all three: a repository-wide default (1000:1000 + fsGroup# 1000), a per-recipe tweak, and a one-shot Snapshot that overrides the recipe,# to show the layering. See the Permissions guide for how to find the right# numbers.## Field shapes verified against crates/api: `moverDefaults.{securityContext,# podSecurityContext}` and `spec.mover.securityContext` are standard core/v1# (Pod)SecurityContexts.---apiVersion: v1kind: Secretmetadata: name: app-repo-creds namespace: apptype: 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: app-primary namespace: appspec: backend: s3: bucket: my-backups region: us-east-1 auth: secretRef: name: app-repo-creds encryption: passwordSecretRef: name: app-repo-creds key: KOPIA_PASSWORD create: enabled: true # ── moverDefaults: the PUID/PGID story, set ONCE for the whole repository ───── # Every mover this repository spawns (bootstrap, backup, restore, maintenance) # inherits these. The old KOPIUR_PUID/KOPIUR_PGID env vars are gone — set the # UID/GID here instead. Per-recipe `mover` blocks merge OVER this. moverDefaults: securityContext: runAsUser: 1000 # PUID — the UID that owns the data on every PVC here runAsGroup: 1000 # PGID runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault podSecurityContext: fsGroup: 1000 # make freshly-provisioned restore volumes group-writable fsGroupChangePolicy: OnRootMismatch # Sized kopia cache shared by every mover (former cacheDefaults). cache: capacity: 10Gi storageClassName: fast-ssd # ── RWO Multi-Attach avoidance (default on) ───────────────────────────────── # A ReadWriteOnce PVC attaches to one node at a time. If your app pod holds it # on node A and the mover lands on node B, the mover pod is stuck # "Multi-Attach error". `Auto` (the default — shown here for clarity) pins the # mover to the node the source PVC is attached to, so it co-locates with the # app. Set `Disabled` to opt out, or `Required` to fail loudly when the node # can't be determined for an RWO source. sourceColocation: mode: Auto---apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: appspec: repository: name: app-primary sources: - pvc: name: app-data retention: keepDaily: 14 keepWeekly: 4 # ── Per-recipe override (MERGES over moverDefaults) ───────────────────────── # This SnapshotPolicy mostly inherits the repository's moverDefaults above. If # THIS app's data is owned by a different UID, set just that field here — the # rest (fsGroup, cache, the hardened base) still comes from moverDefaults. A # partial override can only tighten; it never drops drop:[ALL]/seccomp. # region mover mover: securityContext: runAsUser: 1000 # match the UID that owns THIS app's data (else inherit) runAsGroup: 1000 # ── Root mover (LAST RESORT) ────────────────────────────────────────────── # If the data is owned by assorted UIDs you cannot match (e.g. lost+found, or # an app that writes as root), a root mover reads everything. This is ELEVATED: # the workload namespace must opt in first, or the Snapshot is refused: # kubectl annotate namespace app kopiur.home-operations.com/privileged-movers=true # securityContext: # runAsUser: 0 # root (privilegedMode never sets this for you) # runAsNonRoot: false # privilegedMode: true # root only: reads any file; preserves UID/GID on RESTORE# endregion mover---# ── Per-RUN override on a one-shot Snapshot (MERGES over the policy) ───────────# An ad-hoc snapshot before a risky change, which needs more memory and a bigger# kopia content cache than the nightly schedule. Only the fields named here# change; runAsGroup, fsGroup, the cache capacity/class and the hardened base all# still come from the SnapshotPolicy and moverDefaults above.## Precedence, lowest to highest:# moverDefaults < SnapshotPolicy.spec.mover < Snapshot.spec.mover## `mover.cache` here is RUN-SCOPED. It moves this run's kopia cache BUDGETS and,# when the cache is `Ephemeral`, this Job's cache-volume capacity/storageClassName# (an ephemeral volume is bound to this pod and GC'd with it). It cannot change# `cache.mode`, and under `mode: Persistent` the PVC's own capacity/class come from# the SnapshotPolicy alone — that claim is named per-POLICY and shared by every# sibling Snapshot, so a per-run override must not mutate state they depend on.## It also does not escape the privileged-mover gate: an elevated context assembled# here still needs the namespace opt-in, because the gate runs on the MERGED result.# region snapshot-moverapiVersion: 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: 21600# endregion snapshot-mover2. Inherit it from the workload
Section titled “2. Inherit it from the workload”If you would rather say “run as whatever the app runs as” than track a UID, inheritSecurityContextFrom copies the security context from a live workload pod onto the mover. It copies both the container securityContext, which carries the UID and GID, and the pod-level securityContext, which carries things like fsGroup. This is the answer to “back up or restore as the pod that mounts this PVC,” at both levels.
It is an externally-tagged choice, so pick exactly one form. Two of them read a live pod. The third, restore-only snapshot, replays the identity recorded on the backup and needs no pod at all.
pvcConsumer — auto-derive from the source PVC (backup, recommended)
Section titled “pvcConsumer — auto-derive from the source PVC (backup, recommended)”On a backup, Kopiur can find the pod that mounts the source PVC for you and inherit its security context. You write no selector, and there is nothing to keep in sync:
spec: mover: inheritSecurityContextFrom: pvcConsumer: {} # optionally: pvcConsumer: { container: app }The controller lists pods in the source namespace, finds the one mounting this snapshot’s source PVC while excluding Kopiur’s own mover pods, prefers a Running one, and copies its container and pod securityContext onto the mover. If no workload pod currently mounts the PVC, for example because it is scaled to zero, the Backup is held with SecurityContextResolved=False / InheritSourceMissing, whose message names the PVC whose consumer was missing, so scale the workload up. If you would rather it kept running, give the mover an explicit securityContext that pins a runAsUser. That becomes the fallback, and the run proceeds on it with a SecurityContextInherited=False / InheritFallback condition instead of being held. See Combining inherit with an explicit context.
pvcConsumer is backup-only. A Restore writes a target PVC whose consumer may not exist yet, so use workloadSelector below, or the recorded-identity snapshot mode, on a Restore. The admission webhook rejects pvcConsumer on a Restore.
workloadSelector — name the workload by label (backup or restore)
Section titled “workloadSelector — name the workload by label (backup or restore)”spec: mover: inheritSecurityContextFrom: workloadSelector: podSelector: matchLabels: app.kubernetes.io/name: app # the workload that owns the PVC container: app # optional; defaults to the pod's first containerUse this on a Restore, to inherit from the pod that will read the restored data. Use it on a backup when you would rather pin the selection explicitly. To find the right labels, list the pods that mount the claim:
$ kubectl get pods -n app -o json \ | jq -r '.items[] | select(.spec.volumes[]?.persistentVolumeClaim.claimName=="app-data") | .metadata.name'app-7c9d8f5b6-h2k4p
$ kubectl get pod app-7c9d8f5b6-h2k4p -n app --show-labelsHow it resolves: the controller lists pods matching the selector, prefers a Running one, picks the named container or the pod’s first, and copies that container’s securityContext and the pod’s pod-level securityContext onto the mover. The matched workload must be running so its identity can be read.
The Backup or Restore is held with SecurityContextResolved=False / InheritSourceMissing if no pod matches, if the selector is empty, if the named container is absent, or if the pod sets neither a container nor a pod-level securityContext. The condition message names the selector that matched nothing and lists the three ways out. The exception is when the recipe also sets a mover.securityContext pinning a runAsUser. That is then used as the fallback, reported as InheritFallback, and the run proceeds.
Things to remember:
- Inheriting a root workload is still elevated. The resolved contexts are what get evaluated, container and pod. So inheriting from a pod that runs as root, or with
runAsUser: 0at either level, or with added capabilities, trips the privileged-mover gate exactly like an explicit root context would. - Inheriting root just works, and you don’t hand-set
runAsNonRoot. When the workload runs asrunAsUser: 0, Kopiur produces a valid root mover for you. It reconcilesrunAsNonRoottofalse, becauserunAsNonRoot: trueplusrunAsUser: 0is a contradiction the kubelet rejects withCreateContainerConfigError. So you only opt the namespace into privileged movers. You never need to addrunAsNonRoot: falseto aninheritSecurityContextFromrecipe.
snapshot — inherit the backup’s recorded identity (restore)
Section titled “snapshot — inherit the backup’s recorded identity (restore)”Every backup records the uid, gid and fsGroup its mover actually ran as, on the kopia snapshot itself as the kopiur-meta tag, and as Snapshot.status.recorded. On a Restore, snapshot: {} replays that recorded identity onto the restore mover:
spec: mover: inheritSecurityContextFrom: snapshot: {} # the empty sub-object is required — see the warning belowUnlike workloadSelector, this needs no live pod. It works when the app is not deployed yet, after a full cluster re-bootstrap, and with target: { populator: {} }, where it is the one inherit mode allowed. It works with every restore source:
snapshotRefreads the referencedSnapshotCR’sstatus.recordeddirectly.fromPolicy/identitysearch the namespace’s Snapshot CRs by kopia identity, honoringasOf,offset, andsnapshotID. That makes a purely declarative re-bootstrap work: apply Repository, SnapshotPolicy and Restore, and let the catalog scan materialize the rows the search needs.
If no recorded identity is resolvable yet, the Restore holds with SecurityContextInherited=False / MissingRecordedIdentity and re-checks every few minutes. That happens when the CR is missing, when it carries no status.recorded because it is a pre-feature or foreign backup, or when the catalog scan hasn’t landed. The scan backfills status.recorded automatically whenever the kopia snapshot carries the tag. An explicit mover.securityContext that pins a runAsUser acts as the fallback, reported as InheritFallback, exactly as with the live-pod modes. Explicit fields override recorded ones the same way, so recorded values fill in only what you left blank.
The SecurityContextInherited condition then reports what the record achieved:
status / reason |
What happened | What to do |
|---|---|---|
True / RecordedApplied |
The recorded identity was applied. The message names the Snapshot, the recorded uid/gid/fsGroup, and where the identity came from. inherited means it tracked the workload at backup time; explicit or defaults means it reproduces the backup mover’s identity, which was never workload-derived. |
Nothing. |
False / RecordedPinnedNoUid |
The backup recorded no pinned uid, and no group beyond the mover’s own defaults. Its mover’s identity was image-determined, so there is nothing to replay, and the restore mover runs as its image’s uid 65532. |
Pin mover.securityContext.runAsUser on the Restore, or pin an identity on the backup’s mover so future snapshots record one. |
False / MissingRecordedIdentity |
No recorded identity is resolvable yet (see above). The Restore holds. | Usually nothing: wait for the catalog scan. Or set an explicit mover.securityContext, or drop snapshot: {}. |
snapshot is restore-only. A backup’s identity comes from the live workload through pvcConsumer or workloadSelector, because the backup is the run that creates the record, and maintenance touches no snapshot data. The admission webhook rejects the variant on SnapshotPolicy and Maintenance.
Apply-ready example (direct plus declarative re-bootstrap):
# 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: {}Combining inherit with an explicit context
Section titled “Combining inherit with an explicit context”inheritSecurityContextFrom and securityContext/podSecurityContext are layers, not alternatives. You can set both. The full merge order is:
hardened ⊂ moverDefaults ⊂ inherited ⊂ mover.securityContextSo what you write always wins, inheritance fills in whatever the workload pins that you left blank, and your context stands in alone when inheritance can’t resolve a pod. That last property makes it the natural fallback.
Layers merge as (container, pod) pairs. The mover’s identity, meaning runAsUser and runAsGroup, always belongs to the highest layer that pins one, regardless of which level it was written at. So a workload that pins runAsUser at the pod level beats a moverDefaults.securityContext.runAsUser written at the container level. Kubernetes alone would resolve that the other way round, container beating pod, but Kopiur promotes the winning layer’s identity onto the mover container, which is the pod’s only container. So the ladder above is the whole story. Non-identity fields such as runAsNonRoot and seccompProfile merge field by field, per level.
# ── Inherit AND override: the two are merge layers ────────────────────────────# Order: hardened ⊂ moverDefaults ⊂ inherited ⊂ mover.securityContext.# What you write always wins; inherit fills in what you leave blank; and what you# wrote is all that's left when no workload pod can be resolved (scaled to zero).apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data-merged namespace: appspec: repository: name: app-primary sources: - pvc: name: app-data retention: keepDaily: 14 mover: inheritSecurityContextFrom: pvcConsumer: {} securityContext: # NOTE: this is an OVERRIDE, not a default — it pins the mover to 1000 even # when the workload resolves to a different UID. There is deliberately no # "prefer the workload's UID, else 1000". Omit runAsUser to let inherit # supply it. Kopiur raises SecurityContextInherited=False/InheritOverridden # naming both UIDs when an explicit UID displaces a resolved one. runAsUser: 1000 podSecurityContext: # Join the repo backend's shared group WITHOUT disturbing the inherited UID — # supplementalGroups is additive, so this composes with inherit rather than # fighting it (see the NFS shared-group recipe in the docs). supplementalGroups: [3001]The pattern that composes best is overriding a group while inheriting the identity, as above. supplementalGroups is additive, so it doesn’t fight inherit the way a runAsUser override does.
The SecurityContextResolved condition
Section titled “The SecurityContextResolved condition”There are two different questions, and they get two different conditions. SecurityContextResolved answers “could the mover’s identity be worked out at all?” — it is the hold. SecurityContextInherited, below, answers “did inheriting do what you think it did?” on a run that went ahead anyway — it is advisory.
SecurityContextResolved is written only when the answer is no, and only for the live-pod modes (workloadSelector, pvcConsumer):
status / reason |
What happened | What to do |
|---|---|---|
False / InheritSourceMissing |
A live-pod inherit resolved nothing — no pod matched the selector, the named container is absent, or the matched pod sets neither a container nor a pod-level securityContext — and the recipe pins no fallback runAsUser. The run is parked at phase: Pending and re-checked every few minutes. The message names the selector (or the source PVC) that found nothing, and quotes the resolver’s own diagnosis and fix. |
Bring the workload back up, correct the selector, or set mover.securityContext.runAsUser. The run starts by itself once one of those is true; no re-apply. |
True / InheritSourceResolved |
A previously-held run resolved its mover identity and is proceeding. | Nothing. |
$ kubectl get snapshot pg-backup -o jsonpath='{.status.conditions[?(@.type=="SecurityContextResolved")]}'{"type":"SecurityContextResolved","status":"False","reason":"InheritSourceMissing", "message":"mover.inheritSecurityContextFrom (workloadSelector `app=postgres`) found nothing to inherit and no fallback UID is set, so the run is held rather than run as the wrong UID. ..."}Holding is deliberate: a backup taken as the wrong UID is worse than a backup that did not run. This is a registered structural gate, so kubectl kopiur doctor reports it as a blocked run rather than passing a cluster whose backups are quietly parked. One Warning Event fires per transition, not per reconcile.
The condition is written only when the answer is no, and cleared when it becomes yes. A run that was never held never carries it at all, and a held run that recovers flips to True / InheritSourceResolved before its mover Job starts — otherwise doctor, which forgives a stale gate only on a terminal phase, would keep calling a Running backup blocked for the whole run.
The restore-only inheritSecurityContextFrom.snapshot mode has its own hold, reported on SecurityContextInherited as MissingRecordedIdentity — see snapshot — inherit the backup’s recorded identity.
The SecurityContextInherited condition
Section titled “The SecurityContextInherited condition”SecurityContextCompatible answers “can the mover read the source?”. SecurityContextInherited answers a different question: “did inheriting do what you think it did?”. It appears on every run that asks to inherit, and not at all on runs that don’t. Every state below describes a run that proceeded — it is advisory and never blocks. A run that could not proceed carries SecurityContextResolved=False instead.
status / reason |
What happened | What to do |
|---|---|---|
True / InheritApplied |
Inheritance resolved a workload and its values survived every layer. The message names the pod and the uid the mover runs as. | Nothing. |
False / InheritFallback |
No workload pod resolved: scaled to zero, mid-rollout, or the selector matches nothing. Your explicit context stood in, so the run proceeded rather than being held with SecurityContextResolved=False. |
Nothing, if that’s the intent. Otherwise scale the workload up. |
False / InheritPinnedNoUid |
A pod resolved, but it pins no runAsUser and no group beyond the mover’s own defaults. Its identity is in its image, which Kopiur cannot read, so inheriting copied nothing. |
Set runAsUser on the workload, or set mover.securityContext.runAsUser. |
False / InheritOverridden |
Inheritance resolved a UID, but this recipe’s explicit runAsUser overrode it. That is correct by design, but inherit is a no-op for that field and won’t follow the workload. The message names the exact field that won, either mover.securityContext.runAsUser or mover.podSecurityContext.runAsUser. Only this recipe can displace an inherited UID; the repository’s moverDefaults never can. |
Remove the named runAsUser to track the workload, or drop inheritSecurityContextFrom. |
$ kubectl get snapshot pg-backup -o jsonpath='{.status.conditions[?(@.type=="SecurityContextInherited")]}'The False states each emit one Warning Event, fired on the status transition rather than every reconcile. True is a silent confirmation. Because the healthy state is reported rather than merely implied by silence, fixing your recipe clears a previous warning instead of leaving it to rot.
A Restore reports InheritFallback the same way. It has no InheritPinnedNoUid. A restore that inherits only an fsGroup is a blessed configuration, because the target is a fresh read-write volume the kubelet does apply fsGroup to. Warning there would flag a setup Kopiur itself certifies.
Two consequences worth internalizing:
- Only
runAsUserde-escalates an inherited root UID. SettingrunAsNonRoot: trueagainst an inheritedrunAsUser: 0does not produce a non-root mover. The kubelet rejects that pair, so Kopiur normalizes it to a root mover, still behind the gate. OverriderunAsUseritself. - Partial overrides only tighten. The hardened base still supplies
drop: [ALL]and the seccomp profile. Setting one field never drops the rest.
Full, apply-ready example (a SnapshotPolicy plus the same knob on a Restore):
# Example 18 — Inherit the mover's UID/GID from the workload## Instead of hard-coding `spec.mover.securityContext.runAsUser/runAsGroup` to# match the data owner (example 09), let the mover "run as whatever the app runs# as": `inheritSecurityContextFrom` copies the securityContext from a live# workload pod onto the mover. This is the answer to "back up / restore as the pod# that mounts this PVC" — you point a LABEL SELECTOR at that workload (Kubernetes# has no way to look up which pod mounts a given PVC, so you select it by the# labels it already carries; see the recipe in the Permissions guide).## PREREQUISITE: the workload must pin `runAsUser` (container or pod level). A UID# that comes from the container image's `USER` line is invisible in the pod spec,# so there is nothing to inherit and the mover would silently run as its own# image's UID (65532) and fail to read the source.## `securityContext` and `inheritSecurityContextFrom` COMBINE (see the bottom of# this file): the explicit context is the higher merge layer, so it overrides the# inherited values field-wise and stands in alone when no pod can be resolved.# A pod matching the selector must be Running so its UID/GID can be read. An# inherited *root* context is still elevated and needs the namespace opt-in.## Field shapes verified against crates/api:# spec.mover.inheritSecurityContextFrom : exactly one of# { workloadSelector: { podSelector: LabelSelector, container? } } (backup or restore)# { pvcConsumer: { container? } } (backup source only)# (externally-tagged backend: the bucket lives under `backend.s3`)---apiVersion: v1kind: Secretmetadata: name: app-repo-creds namespace: apptype: 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: app-primary namespace: appspec: backend: s3: bucket: my-backups region: us-east-1 auth: secretRef: name: app-repo-creds encryption: passwordSecretRef: name: app-repo-creds key: KOPIA_PASSWORD create: enabled: true---# region policyapiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: appspec: repository: name: app-primary sources: - pvc: name: app-data retention: keepDaily: 14 keepWeekly: 4 # ── Inherit the mover's UID/GID from the workload ────────────────────────── # Copy the securityContext from the live pod that runs (and mounts) this PVC, # selected by the labels the workload already carries. The mover then reads the # data as the same UID/GID the app writes it as — no hard-coded UID to track. # This only works if that pod PINS `runAsUser` (see the header). mover: inheritSecurityContextFrom: # On a BACKUP source the simplest form is `pvcConsumer: {}` — the operator # finds the pod that mounts `app-data` and inherits its securityContext, with # no selector to maintain: # # pvcConsumer: {} # optionally: pvcConsumer: { container: app } # # Name the container when the pod has sidecars — otherwise the FIRST container # is used, which on an injected pod may be istio-proxy rather than your app. # # Or name the workload explicitly with a label selector: workloadSelector: podSelector: matchLabels: app.kubernetes.io/name: app # the workload that owns `app-data` container: app # optional; defaults to the pod's first container# endregion policy---# region merged# ── Inherit AND override: the two are merge layers ────────────────────────────# Order: hardened ⊂ moverDefaults ⊂ inherited ⊂ mover.securityContext.# What you write always wins; inherit fills in what you leave blank; and what you# wrote is all that's left when no workload pod can be resolved (scaled to zero).apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data-merged namespace: appspec: repository: name: app-primary sources: - pvc: name: app-data retention: keepDaily: 14 mover: inheritSecurityContextFrom: pvcConsumer: {} securityContext: # NOTE: this is an OVERRIDE, not a default — it pins the mover to 1000 even # when the workload resolves to a different UID. There is deliberately no # "prefer the workload's UID, else 1000". Omit runAsUser to let inherit # supply it. Kopiur raises SecurityContextInherited=False/InheritOverridden # naming both UIDs when an explicit UID displaces a resolved one. runAsUser: 1000 podSecurityContext: # Join the repo backend's shared group WITHOUT disturbing the inherited UID — # supplementalGroups is additive, so this composes with inherit rather than # fighting it (see the NFS shared-group recipe in the docs). supplementalGroups: [3001]# endregion merged---# ── The SAME knob works on a Restore ──────────────────────────────────────────# A Restore writes INTO a PVC, so it has the identical mover surface. Inheriting# from the app pod makes the restored files land owned exactly as the app expects.# Uncomment and adjust to restore as the workload's UID/GID:## apiVersion: kopiur.home-operations.com/v1alpha1# kind: Restore# metadata:# name: app-data-restore# namespace: app# spec:# source:# snapshotRef:# name: app-data-20260601-020000# target:# pvcRef:# name: app-data # write back into the existing PVC# mover:# inheritSecurityContextFrom:# # On a RESTORE the consuming pod may not exist yet, so `pvcConsumer` does# # not apply — name the future workload by label instead:# workloadSelector:# podSelector:# matchLabels:# app.kubernetes.io/name: app# container: app3. Go root (privileged mover)
Section titled “3. Go root (privileged mover)”When the data is owned by assorted UIDs you can’t match, such as a lost+found, a multi-user volume, or an app that writes as root, a privileged root mover reads everything. On restore it can also reproduce original ownership. It is elevated and gated, as covered below:
spec: mover: securityContext: runAsUser: 0 runAsNonRoot: false privilegedMode: true # grants the capabilities root needs; see belowrunAsUser: 0 on its own is not enough. The mover keeps the hardened capabilities.drop: ["ALL"], and root without capabilities is subject to the same owner/group/other permission checks as any other user. A 0660 file owned by 1000:1000 is unreadable to it. privilegedMode: true is what adds the capabilities back.
What privilegedMode grants
Section titled “What privilegedMode grants”privilegedMode: true never changes the UID. When the mover runs as root (runAsUser: 0, which you set yourself), it adds the Linux capabilities the operation needs, on top of drop: ["ALL"]:
| Operation | Capabilities added | Why |
|---|---|---|
Backup (SnapshotPolicy / Snapshot) |
DAC_OVERRIDE |
Read every file and directory, whatever its owner and mode. |
| Restore | CHOWN, DAC_OVERRIDE, FOWNER, FSETID |
Write anywhere in the target, give each file its recorded owner, then set its mode and times without losing setuid/setgid bits. |
| Maintenance | none | It only talks to the repository, never to a source or target volume. |
The rules:
- Capabilities are granted only when the effective
runAsUseris0. It can come fromsecurityContext,podSecurityContext, the repository’smoverDefaults, or an inherited context. - Without a root UID,
privilegedModedoes nothing. That covers a non-zerorunAsUserand norunAsUserat all (the image runs as65532). Kubernetes gives a non-root container no usable capabilities, so nothing is granted and the UID is left alone. The operator emits aPrivilegedModeInertWarning Event on theSnapshotorRestore. The webhook also warns at apply time when the object itself pins a non-zerorunAsUser. With norunAsUseron the object it stays silent, because the repository’smoverDefaultsmay setrunAsUser: 0, and only the operator sees that merge. Fix it by settingrunAsUser: 0andrunAsNonRoot: false, or by droppingprivilegedMode. - It never changes who writes the repository. The mover’s UID owns the blobs it writes to a filesystem repository (NFS, hostPath). A root mover writes root-owned blobs that a non-root maintenance, verify, delete or pin mover cannot read, and an NFS export with
root_squashrefuses root writes outright. Give a root mover its own filesystem repository, or run every mover of that repository as root. Object-storage repositories (S3, B2, Azure, GCS) are unaffected. - Any
capabilities.addyou set is kept. The grant is added to your list, never replacing it.
Every capability above is on the Pod Security Standards baseline allow list, so a privileged mover is admitted in a baseline-enforced namespace. It is never admitted under restricted, which requires runAsNonRoot: true. Kopiur deliberately grants DAC_OVERRIDE rather than DAC_READ_SEARCH for backups: DAC_READ_SEARCH is narrower, but it is not on the baseline list.
Catching permission mismatches early
Section titled “Catching permission mismatches early”A mover that can’t read the data it’s backing up is the classic footgun. By default kopia treats an unreadable file or directory as fatal: the backup fails loudly with permission denied, classified PermissionDenied, so nothing is silent. The dangerous case is when you have set an ignoreFileErrors or ignoreDirErrors policy. kopia then completes the snapshot while skipping the files it couldn’t read, leaving you with a silently incomplete backup. Kopiur surfaces all of this:
-
At
kubectl apply(admission warning). When a SnapshotPolicy’ssource.pvcis mounted by a workload whose UID the mover’s explicitrunAsUserclearly can’t match, meaning no shared UID or group, the webhook attaches a non-blocking warning to the apply. It is best-effort: it can’t see file modes, and the workload may not be running yet. -
On reconcile (status condition). A Backup’s
SecurityContextCompatiblecondition is positive-only and certain. It is never a guess:True: provably fine, checked against the resolved mover identity and the live workloads mounting the source. Either the mover is root withDAC_OVERRIDE(whichprivilegedModegrants; plainrunAsUser: 0is not enough), or its UID exactly matches every container that writes the source, init containers included. UsinginheritSecurityContextFromis not itself a basis: inheriting only helps if the workload actually pins arunAsUser, and the condition saysTrueonly once it has confirmed the UIDs match.False: set only by the certain post-run signal, number 3 below, which is that the completed backup actually excluded unreadable entries. It is never set from an up-front heuristic, so a successful backup of world-readable data is never falsely flagged.- Absent: the common case. Not provable from the spec alone, because nobody pinned a UID, or several UIDs write the volume. Absence is not a warning. It means “no claim”, and the run proceeds.
Terminal window $ kubectl get snapshot pg-backup -o jsonpath='{.status.conditions[?(@.type=="SecurityContextCompatible")]}'A Restore carries the matching
RestoreSecurityContextCompatiblecondition, which is also positive-only. It readsTruewhen the future consumer can read what the mover writes, through a matching UID or a sharedfsGroup. A restore has no certain runtime signal, so its advisory negative lives entirely in the apply-time admission warning. -
From kopia’s own output (the authoritative signal). The mover doesn’t re-walk the tree, because kopia already reports exactly which entries it skipped. When a backup completes with excluded entries, the ignore-errors case, the mover records the count on
status.stats.filesFailed, and the controller raisesSecurityContextCompatible=Falseplus a Warning Event naming the count and the fix. A fatal permission error needs no special handling: kopia exits non-zero and the run already fails asPermissionDenied.Terminal window $ kubectl get snapshot pg-backup -o jsonpath='{.status.stats.filesFailed}'$ kubectl get events --field-selector involvedObject.name=pg-backup
The fix in every case is the same: match the mover to the workload. The easiest way is inheritSecurityContextFrom.pvcConsumer: {}, described above, or a matching runAsUser and fsGroup.
Privileged and root movers
Section titled “Privileged and root movers”Anything that makes the mover’s effective context elevated requires a per-namespace admin opt-in. The “elevated” detector trips on any of:
runAsUser: 0(root)privileged: trueallowPrivilegeEscalation: true- added Linux
capabilities runAsNonRoot: falseprivilegedMode: true
It evaluates the resolved context, so an inherited-from-root mover counts too. If the namespace hasn’t opted in, the Backup or Restore is refused with a clear MoverPermitted=False condition and a Warning Event naming the fix. Opt the namespace in by applying a Namespace carrying the opt-in annotation:
# Privileged-mover namespace opt-in (docs/src/content/docs/permissions.mdx, docs/src/content/docs/security-context.mdx)## A privileged (or root) mover — `runAsUser: 0`, `privileged: true`,# `allowPrivilegeEscalation: true`, added Linux capabilities, `runAsNonRoot:# false`, or `privilegedMode: true` — is refused with a `MoverPermitted=False`# condition until the workload namespace is opted in. This Namespace carries the# opt-in annotation, so backups, restores, and maintenance in `media` may run an# elevated mover.## This is the declarative form of the per-namespace admin decision; applying it# is equivalent to:## kubectl annotate namespace media kopiur.home-operations.com/privileged-movers=true## Granting a namespace privileged movers is a CLUSTER-ADMIN action: the operator# mints a mover ServiceAccount in this namespace, and a tenant here could reuse# it at the mover's privilege. Apply it only for namespaces you trust to run# elevated workloads. Removing the annotation revokes the opt-in.---apiVersion: v1kind: Namespacemetadata: name: media annotations: # The opt-in. Any other value (or absence) leaves the namespace un-opted-in. kopiur.home-operations.com/privileged-movers: "true"$ kubectl apply -f privileged-mover-namespace.yamlOr do it imperatively: kubectl annotate namespace <ns> kopiur.home-operations.com/privileged-movers=true.
The operator watches namespaces, so the annotation takes effect within seconds. The blocked Snapshot or Restore proceeds without being re-applied.
Why the gate exists, and the revoke path, are covered in Movers → Privileged movers. The rationale mirrors VolSync’s privileged-movers model: the operator mints a mover ServiceAccount in the workload namespace, and a tenant there could otherwise reuse it at the mover’s privilege.
Complex circumstances
Section titled “Complex circumstances”Preserving original ownership on restore
Section titled “Preserving original ownership on restore”kopia records each file’s original UID and GID in the snapshot. An unprivileged restore mover writes everything owned by its own UID instead. That is fine when one UID owns everything, and wrong for multi-user data. To restore files with their original ownership, the mover must be able to chown to arbitrary UIDs, which needs root with CHOWN and FOWNER. privilegedMode: true grants those (see What privilegedMode grants):
spec: mover: securityContext: { runAsUser: 0, runAsNonRoot: false } privilegedMode: trueA root restore mover without privilegedMode holds no capabilities. kopia’s ignorePermissionErrors defaults to true, so such a restore completes but every file stays owned by root.
This is the same elevation the gate covers, so the restore namespace must opt in. There is an inherent trade-off: “preserve arbitrary ownership exactly” and “run unprivileged” are largely mutually exclusive.
ReadWriteMany / multi-writer volumes
Section titled “ReadWriteMany / multi-writer volumes”fsGroup ownership-remapping through spec.mover.podSecurityContext.fsGroup is most effective on ReadWriteOnce volumes. On ReadWriteMany it may be a no-op, or ignored, depending on the CSI driver. For ReadWriteMany, match the owning UID and GID directly in the container securityContext. If the volume holds files from several UIDs, use a root mover with privilegedMode: true to read and write regardless of owner.
Mixed ownership, lost+found, root-written data
Section titled “Mixed ownership, lost+found, root-written data”If stat shows several different owners and some files are owner-only (0600), no single non-root UID can read them all. A root mover with privilegedMode: true is the pragmatic answer: it reads every file on backup and restores the original ownership.
NFS sources and filesystem repositories
Section titled “NFS sources and filesystem repositories”- NFS exports often apply
root_squash, which remaps root tonobody, and their own UID mapping server-side. A root mover may not help there. Match the UID the NFS server expects, or relax the export. - A filesystem repository adds a second, separate permission surface: the repository path must be writable by the operator or mover UID. That’s not a
securityContextknob. See Permissions → Filesystem repositories.
NFS filesystem repositories
Section titled “NFS filesystem repositories”A filesystem repository backed by an inline NFS export (backend.filesystem.volume.nfs) is the case where the two surfaces collide. A single mover pod must read the source PVC, as the app’s UID, say 1000, and write the repo backend, which lives on an NFS export owned by some dedicated UID/GID, say 3001. And because fsGroup is a no-op on NFS, you can’t lean on it.
The clean answer is to decouple the two: read the source as the app’s UID, and write the repo through a shared supplemental group. Supplemental GIDs are sent to the NFS server over AUTH_SYS, within the 16-group limit, so a group-writable export grants write without changing the process’s primary UID.
-
On the NAS. Own the export by the shared group and make it group-writable and setgid, so new repo blobs inherit the GID:
Terminal window chown -R root:3001 /export/kopia # or: chown -R 3001:3001chmod -R 2775 /export/kopia # 2 = setgid -
On the repository. Every pod that writes the backend must carry the shared group. That means the bootstrap connect/create Job, every snapshot and maintenance mover, and the kopia-ui server:
spec:moverDefaults:podSecurityContext:supplementalGroups: [3001] # movers + bootstrap join the export's groupserver: # only if you enable the web UIpodSecurityContext:supplementalGroups: [3001] # the long-lived server joins it too -
Per recipe. Source reads stay correct because each
SnapshotPolicyorRestorereads as the app’s identity, for example throughinheritSecurityContextFrom. The supplemental group is additive and doesn’t disturb the primary UID:# SnapshotPolicyspec:mover:inheritSecurityContextFrom:workloadSelector:podSelector: { matchLabels: { app.kubernetes.io/name: my-app } }
Full, apply-ready example:
# Backend: Filesystem over inline NFS, with a UID mismatch (the homelab norm)## The common real-world case: the NFS export is owned by a dedicated user/group# (here UID/GID 3001), but your apps run as assorted UIDs (1000, 568, …). A# filesystem repo's mover is ONE pod that must do two things at once:## * READ the source PVC — as the app's UID (so it can read the data)# * WRITE the repo backend — to an NFS export owned by 3001## `fsGroup` CANNOT bridge this: the kubelet does not chown in-tree NFS mounts, so# fsGroup is silently a no-op on NFS. The fix is to DECOUPLE the two surfaces:# read the source as the app's UID, and write the repo through a SHARED SUPPLEMENTAL# GROUP (supplemental GIDs are honored by NFS over AUTH_SYS).## ON THE NAS, make the export group-writable by the shared GID, with setgid so new# blobs inherit it:# chown -R root:3001 /export/kopia# chmod -R 2775 /export/kopia# (TrueNAS: set the dataset's group, or use a Mapall User/Group to remap all# clients server-side instead — then no pod-side group is needed.)## See the Permissions and Security-context guides. For the simple same-UID case,# see nfs.yaml; for a PVC-backed filesystem repo, see filesystem.yaml.## Field shapes verified against crates/api: ServerSpec.podSecurityContext and# MoverDefaults.podSecurityContext both accept supplementalGroups.---apiVersion: v1kind: Secretmetadata: name: nfs-repo-creds namespace: backupstype: OpaquestringData: KOPIA_PASSWORD: "choose-something-long-and-random"---apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: nas-shared namespace: backupsspec: backend: filesystem: path: /repo volume: nfs: server: nas.lan path: /export/kopia # owned root:3001, mode 2775 on the NAS encryption: passwordSecretRef: name: nfs-repo-creds key: KOPIA_PASSWORD create: enabled: true # Every pod that WRITES the backend — the bootstrap connect/create Job and every # snapshot/maintenance mover — joins the export's group. The primary UID is left # alone (so per-policy source reads stay correct); the group is purely additive. moverDefaults: podSecurityContext: supplementalGroups: [3001] # The kopia-ui web server also mounts and writes the NFS backend, so it needs the # same group. (Omit this whole block if you don't enable the UI.) server: podSecurityContext: supplementalGroups: [3001]---# Per-recipe: read the SOURCE as the app's own identity. The supplemental group# from moverDefaults still applies (it's additive), so the same mover reads the# source as UID 1000 AND writes the repo via group 3001.apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: backupsspec: repository: name: nas-shared sources: - pvc: name: my-app-data retention: keepDaily: 14 keepWeekly: 4 mover: inheritSecurityContextFrom: workloadSelector: podSelector: matchLabels: app.kubernetes.io/name: my-appRestricted namespaces (Pod Security Admission)
Section titled “Restricted namespaces (Pod Security Admission)”The hardened default satisfies the Pod Security Admission (PSA) restricted profile, so unprivileged movers run anywhere. A root or elevated mover violates restricted. Beyond Kopiur’s own opt-in annotation, the namespace’s PSA level, and any OpenShift SCC, must also permit it, or the pod won’t schedule.
Try it end-to-end
Section titled “Try it end-to-end”Prove inherit-from-the-workload end to end. You get a running Deployment whose container runs as UID 1000, and a SnapshotPolicy that copies that identity onto the mover. The mover pod ends up running as 1000, and you never wrote a UID on the policy.
It is one apply-ready bundle, deploy/examples/tryit/inherit-security-context.yaml, containing the app Namespace, a PVC, a long-running app Deployment as the identity source, a Secret, an S3 Repository, an inheritSecurityContextFrom SnapshotPolicy, and a manual Snapshot.
The workload is the identity source. inheritSecurityContextFrom needs a live pod to read from:
# A live workload that runs as UID 1000 and mounts `app-data`. The mover will# COPY this pod's securityContext, so it must be Running before the Snapshot.apiVersion: apps/v1kind: Deploymentmetadata: name: app namespace: app labels: app.kubernetes.io/name: appspec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: app template: metadata: labels: app.kubernetes.io/name: app # the label inheritSecurityContextFrom selects on spec: securityContext: fsGroup: 1000 # make the fresh volume writable by GID 1000 containers: - name: app # the named container the policy inherits from image: busybox:1.36 # Seed the volume once, then idle so the pod stays Running. command: ["sh", "-c"] args: - | set -eu mkdir -p /data/config [ -f /data/config/app.yaml ] || echo "written as uid 1000" > /data/config/app.yaml [ -f /data/blob.bin ] || head -c 2097152 /dev/urandom > /data/blob.bin chmod 0600 /data/config/app.yaml echo "seeded; idling"; sleep infinity securityContext: runAsUser: 1000 # ← the UID the mover will inherit runAsGroup: 1000 runAsNonRoot: true allowPrivilegeEscalation: false capabilities: drop: ["ALL"] seccompProfile: type: RuntimeDefault volumeMounts: - name: d mountPath: /data volumes: - name: d persistentVolumeClaim: claimName: app-dataThe policy points a label selector at it, with no hard-coded UID:
apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: appspec: repository: name: app-primary sources: - pvc: name: app-data retention: keepDaily: 7 keepWeekly: 4 # ── Inherit the mover's UID/GID from the live workload ──────────────────────── # `pvcConsumer: {}` auto-derives the pod that mounts the source PVC (`app-data`) # and copies its securityContext (container UID/GID + pod fsGroup) onto the mover — # no selector to maintain. Combines with `securityContext` (explicit wins); the consuming # pod must be Running. (To name the workload explicitly instead, use # `workloadSelector: { podSelector: {...}, container: app }`.) mover: inheritSecurityContextFrom: pvcConsumer: {}1. Fill in the credentials (AWS_* + KOPIA_PASSWORD) in the secret section, then apply the bundle:
$ kubectl apply -f deploy/examples/tryit/inherit-security-context.yaml2. Wait for the workload to be Running first. Inherit reads a live pod, so the Deployment must be up before the Snapshot:
$ kubectl -n app rollout status deploy/app --timeout=2m$ kubectl -n app wait --for=condition=Ready repository/app-primary --timeout=2m3. Take the backup. The Snapshot uses generateName, so create it:
$ kubectl create -f deploy/examples/tryit/inherit-security-context.yamlsnapshot.kopiur.home-operations.com/app-data-manual-abc12 created
$ kubectl -n app wait --for=jsonpath='{.status.phase}'=Succeeded \ snapshot/app-data-manual-abc12 --timeout=5m4. Prove the inherited identity (deep). The mover pod’s UID equals the workload’s, copied automatically:
# what the WORKLOAD runs as:$ kubectl -n app get pod -l app.kubernetes.io/name=app \ -o jsonpath='{.items[0].spec.containers[?(@.name=="app")].securityContext.runAsUser}{"\n"}'1000
# the mover Job is named after the Snapshot CR (no -snap suffix):$ kubectl -n app get snapshot app-data-manual-abc12 -o jsonpath='{.status.job.name}{"\n"}'app-data-manual-abc12
# the MOVER pod's UID — also 1000, inherited, never set on the policy:$ kubectl -n app get pods --selector=job-name=app-data-manual-abc12$ kubectl -n app get pod <mover-pod> \ -o jsonpath='{.spec.containers[0].securityContext.runAsUser}{"\n"}'1000Backup vs Restore at a glance
Section titled “Backup vs Restore at a glance”| Backup | Restore | |
|---|---|---|
| The mover must… | read the source PVC | write the target PVC |
| Set the UID/GID to… | an identity that can read the data | the identity that should own the restored files |
| Default if unset | UID 65532 (reads world-readable / 65532-owned only), pod fsGroup: 65532 |
UID 65532 (files land owned by 65532), pod fsGroup: 65532 |
| Preserve original ownership | n/a (kopia records it) | needs root + privilegedMode: true (grants CHOWN, DAC_OVERRIDE, FOWNER, FSETID) |
| Inherit from workload | SnapshotPolicy.spec.mover.inheritSecurityContextFrom (pvcConsumer/workloadSelector) |
Restore.spec.mover.inheritSecurityContextFrom (workloadSelector, or snapshot: {}, the backup’s recorded identity, no live pod) |
| Override it for ONE run | Snapshot.spec.mover, merged field-wise over the policy’s — see Backups → mover for one run |
a Restore is the invocation, so Restore.spec.mover is already per-run |
| Elevated context | namespace privileged-movers opt-in |
same opt-in |
| Tolerate permission errors | fails on unreadable files | spec.options.ignorePermissionErrors (default true) reports instead of failing |
Verify what the mover actually ran as
Section titled “Verify what the mover actually ran as”After a run, confirm the mover’s effective identity and that it actually moved data:
# the mover Job's name is on the owning Snapshot/Restore (named after the CR); find its pod from that:$ kubectl get snapshot <snapshot-name> -n app -o jsonpath='{.status.job.name}'app-data-manual-abc12$ kubectl get pods -n app --selector=job-name=app-data-manual-abc12
# the container's effective UID (sanity-check it matches the data owner):$ kubectl get pod <mover-pod> -n app \ -o jsonpath='{.spec.containers[0].securityContext.runAsUser}{"\n"}'1000
# permission errors, if any:$ kubectl logs <mover-pod> -n app | grep -i "permission denied"A backup that reports Succeeded but zero files and bytes is the classic sign that the mover couldn’t read the source. Recheck the UID. The full verification workflow, covering status conditions and what a healthy run looks like, is in Permissions → Verify it worked.
Quick reference
Section titled “Quick reference”| Thing | Value |
|---|---|
| Where to set it | spec.mover.securityContext (container) + spec.mover.podSecurityContext (pod) on SnapshotPolicy / Restore / Maintenance |
fsGroup |
spec.mover.podSecurityContext.fsGroup: make a fresh restore volume writable by an unprivileged mover. Defaults to 65532 so the kopia cache is writable; override for a restore that must own files as the app’s GID |
| Default | container: UID 65532, runAsNonRoot: true, drop ALL caps, seccomp RuntimeDefault, no escalation. pod: fsGroup: 65532, fsGroupChangePolicy: OnRootMismatch |
| Set the UID/GID | securityContext.runAsUser / runAsGroup (match the data owner) |
| Inherit from a workload | inheritSecurityContextFrom.podSelector (+ optional container); copies container and pod context (UID + fsGroup). Needs the workload to pin runAsUser; combines with securityContext/podSecurityContext, which override it field-wise and act as the fallback |
| Inherit the backup’s recorded identity (restore) | inheritSecurityContextFrom: { snapshot: {} }: replay the uid/gid/fsGroup recorded on the backup (Snapshot.status.recorded); works with every source, needs no live pod, holds on MissingRecordedIdentity until the catalog scan lands |
| Root / preserve ownership | runAsUser: 0 + runAsNonRoot: false + privilegedMode: true (backup gets DAC_OVERRIDE; restore gets CHOWN, DAC_OVERRIDE, FOWNER, FSETID). Root without privilegedMode holds no capabilities |
| Privileged-mover opt-in | kubectl annotate namespace <ns> kopiur.home-operations.com/privileged-movers=true |
| Find the owning UID | Permissions → Find the UID/GID |
See also
Section titled “See also”- Permissions, UID & GID: the task-oriented “my backup reads nothing / my restore is unreadable” workflow.
- Movers, RBAC & credentials: privileged movers, the minted ServiceAccount, credential placement.
- Restores: restore targets, options, and
ignorePermissionErrors. - Example 09 · Example 18.