Skip to content

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.

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

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

This 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 match

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

The whole problem reduces to one number, sometimes two: the numeric UID and GID that own the data.

  1. Find the owner. Read it from the running app with kubectl exec … -- id or ls -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 is 0600 owned by 1000, the mover must be UID 1000; if everything is at least group-readable and shares a GID, matching the GID is enough.
  2. 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.
  3. Choose how to express it: one of the three approaches below.

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

Full, apply-ready example:

deploy/examples/09-mover-permissions.yaml
# 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: v1
kind: Secret
metadata:
name: app-repo-creds
namespace: app
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: app-primary
namespace: app
spec:
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/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: app
spec:
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-mover
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Snapshot
metadata:
# 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: app
spec:
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-mover

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.

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 container

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

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

How 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: 0 at 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 as runAsUser: 0, Kopiur produces a valid root mover for you. It reconciles runAsNonRoot to false, because runAsNonRoot: true plus runAsUser: 0 is a contradiction the kubelet rejects with CreateContainerConfigError. So you only opt the namespace into privileged movers. You never need to add runAsNonRoot: false to an inheritSecurityContextFrom recipe.

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 below

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

  • snapshotRef reads the referenced Snapshot CR’s status.recorded directly.
  • fromPolicy / identity search the namespace’s Snapshot CRs by kopia identity, honoring asOf, offset, and snapshotID. 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):

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: {}

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

So 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/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data-merged
namespace: app
spec:
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.

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

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.
Terminal window
$ 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 runAsUser de-escalates an inherited root UID. Setting runAsNonRoot: true against an inherited runAsUser: 0 does not produce a non-root mover. The kubelet rejects that pair, so Kopiur normalizes it to a root mover, still behind the gate. Override runAsUser itself.
  • 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):

deploy/examples/18-inherit-security-context.yaml
# 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: v1
kind: Secret
metadata:
name: app-repo-creds
namespace: app
type: Opaque
stringData:
AWS_ACCESS_KEY_ID: "REPLACE_ME"
AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: app-primary
namespace: app
spec:
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 policy
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: app
spec:
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/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data-merged
namespace: app
spec:
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: app

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 below

runAsUser: 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.

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 runAsUser is 0. It can come from securityContext, podSecurityContext, the repository’s moverDefaults, or an inherited context.
  • Without a root UID, privilegedMode does nothing. That covers a non-zero runAsUser and no runAsUser at all (the image runs as 65532). Kubernetes gives a non-root container no usable capabilities, so nothing is granted and the UID is left alone. The operator emits a PrivilegedModeInert Warning Event on the Snapshot or Restore. The webhook also warns at apply time when the object itself pins a non-zero runAsUser. With no runAsUser on the object it stays silent, because the repository’s moverDefaults may set runAsUser: 0, and only the operator sees that merge. Fix it by setting runAsUser: 0 and runAsNonRoot: false, or by dropping privilegedMode.
  • 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_squash refuses 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.add you 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.

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:

  1. At kubectl apply (admission warning). When a SnapshotPolicy’s source.pvc is mounted by a workload whose UID the mover’s explicit runAsUser clearly 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.

  2. On reconcile (status condition). A Backup’s SecurityContextCompatible condition 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 with DAC_OVERRIDE (which privilegedMode grants; plain runAsUser: 0 is not enough), or its UID exactly matches every container that writes the source, init containers included. Using inheritSecurityContextFrom is not itself a basis: inheriting only helps if the workload actually pins a runAsUser, and the condition says True only 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 RestoreSecurityContextCompatible condition, which is also positive-only. It reads True when the future consumer can read what the mover writes, through a matching UID or a shared fsGroup. A restore has no certain runtime signal, so its advisory negative lives entirely in the apply-time admission warning.

  3. 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 raises SecurityContextCompatible=False plus 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 as PermissionDenied.

    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.

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: true
  • allowPrivilegeEscalation: true
  • added Linux capabilities
  • runAsNonRoot: false
  • privilegedMode: 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:

deploy/examples/privileged-mover-namespace.yaml
# 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: v1
kind: Namespace
metadata:
name: media
annotations:
# The opt-in. Any other value (or absence) leaves the namespace un-opted-in.
kopiur.home-operations.com/privileged-movers: "true"
Terminal window
$ kubectl apply -f privileged-mover-namespace.yaml

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

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

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

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 exports often apply root_squash, which remaps root to nobody, 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 securityContext knob. See Permissions → 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.

  1. 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:3001
    chmod -R 2775 /export/kopia # 2 = setgid
  2. 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 group
    server: # only if you enable the web UI
    podSecurityContext:
    supplementalGroups: [3001] # the long-lived server joins it too
  3. Per recipe. Source reads stay correct because each SnapshotPolicy or Restore reads as the app’s identity, for example through inheritSecurityContextFrom. The supplemental group is additive and doesn’t disturb the primary UID:

    # SnapshotPolicy
    spec:
    mover:
    inheritSecurityContextFrom:
    workloadSelector:
    podSelector: { matchLabels: { app.kubernetes.io/name: my-app } }

Full, apply-ready example:

deploy/examples/backends/nfs-shared-group.yaml
# 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: v1
kind: Secret
metadata:
name: nfs-repo-creds
namespace: backups
type: Opaque
stringData:
KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: nas-shared
namespace: backups
spec:
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/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: backups
spec:
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-app

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

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/v1
kind: Deployment
metadata:
name: app
namespace: app
labels:
app.kubernetes.io/name: app
spec:
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-data

The policy points a label selector at it, with no hard-coded UID:

apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: app
spec:
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:

Terminal window
$ kubectl apply -f deploy/examples/tryit/inherit-security-context.yaml

2. Wait for the workload to be Running first. Inherit reads a live pod, so the Deployment must be up before the Snapshot:

Terminal window
$ kubectl -n app rollout status deploy/app --timeout=2m
$ kubectl -n app wait --for=condition=Ready repository/app-primary --timeout=2m

3. Take the backup. The Snapshot uses generateName, so create it:

Terminal window
$ kubectl create -f deploy/examples/tryit/inherit-security-context.yaml
snapshot.kopiur.home-operations.com/app-data-manual-abc12 created
$ kubectl -n app wait --for=jsonpath='{.status.phase}'=Succeeded \
snapshot/app-data-manual-abc12 --timeout=5m

4. Prove the inherited identity (deep). The mover pod’s UID equals the workload’s, copied automatically:

Terminal window
# 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"}'
1000
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

After a run, confirm the mover’s effective identity and that it actually moved data:

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

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