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, the fields that matter, the three ways to set it, how to work out the right values, and how to handle the awkward cases (mixed ownership, RWX volumes, preserving ownership on restore, restricted namespaces).

The mental model: the mover is a separate pod

A backup or restore does not run inside your app's pod. Kopiur launches a short-lived mover Job that mounts the PVC and runs kopia. Linux file permissions don't care that it's "your" data — they only see the UID/GID the mover process runs as. The security context is how you control that identity.

  • Backup — the mover must be able to read every file in the source.
  • Restore — the mover must be able to write into the target (and, ideally, land files owned correctly for the app).

What "security context" means here

A Kubernetes SecurityContext is the block that sets a container's Linux identity and privileges: the UID/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). For pod-level settings — most importantly fsGroup — there's 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

fsGroup for a freshly-provisioned restore volume

fsGroup is a pod-level setting (PodSecurityContext). Set it via spec.mover.podSecurityContext.fsGroup (the same PodSecurityContext you'd put on any pod). On mount, the kubelet makes the volume group-owned by that GID and group-writable, and adds it to the mover's supplementary groups — so an unprivileged mover (runAsUser: 1000) can populate a freshly-provisioned volume on restore (whose mount point is otherwise root-owned 0755) without a root mover.

It already defaults to 65532 (the mover image's GID), so the default mover writes a fresh volume with no extra config. You only set fsGroup here when the mover runs as a different UID/GID (below) — match it to that identity:

spec:
    mover:
        securityContext: { runAsUser: 1000, runAsNonRoot: true } # container: who writes
        podSecurityContext: # pod: make the volume writable
            fsGroup: 1000
            fsGroupChangePolicy: OnRootMismatch # skip the recursive chown when already correct

A pod-level runAsUser: 0 / runAsNonRoot: false here is still treated as a privileged mover (it needs the namespace opt-in); fsGroup itself is not elevation.

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

This default is compatible with 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 (1000, 1001, 999, …) and write files 0600/0640, so the default mover gets permission denied on real app data. Whenever the source isn't world-readable, you'll set the context to match the data — read on.

At the pod level, every mover (backup, restore, maintenance, and the bootstrap connect/create Job) also gets a hardened default podSecurityContext:

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 (moverDefaults.cache.mode: Ephemeral/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. Because this is the lowest merge layer, moverDefaults.podSecurityContext and a recipe's mover.podSecurityContext override it field-wise (e.g. set fsGroup to your app's GID for a restore; the rest of the hardened defaults stay put).

fsGroup has no effect on NFS

fsGroup works by having the kubelet chown the volume on mount. The kubelet skips that chown entirely for in-tree nfs: volumes (and many NFS-backed CSI drivers) — so fsGroup is silently a no-op on NFS, not just on root-squashed exports. Two consequences:

  • A kopia cache on an NFS StorageClass stays root:root and the mover gets permission denied. A content-addressed scratch cache has no business on networked storage anyway — leave moverDefaults.cache unset (the default is a node-local emptyDir, always writable) or point cache.storageClass at a block class (e.g. Ceph RBD) that honors fsGroup.
  • An inline-NFS filesystem repository can't be made writable with fsGroup. Use supplementalGroups against a group-writable export, runAsUser matching the export owner, or remap server-side — see NFS filesystem repositories below. The admission webhook warns when an NFS filesystem repo relies only on fsGroup.
  • An NFS backup source is the same story, which is why sources[].readOnly: false is rejected on one: the flag exists only to make fsGroup apply, and on NFS it never can.

fsGroup has no effect on a backup source either, by default

A backup source is mounted read-only (kopia only reads it), and the kubelet skips its fsGroup chown on a read-only mount just as it does on NFS. So fsGroup/fsGroupChangePolicy are silently inert on the source unless you set sources[].readOnly: false.

Under copyMethod: Snapshot/Clone that is safe — the chown lands on the throwaway staged PVC. Under copyMethod: Direct it rewrites your live volume and requires acknowledgeLiveMutation: true. See Copy methods → making fsGroup apply to the source.

How to decide what to set

The whole problem reduces to one number (sometimes two): the numeric UID/GID that owns the data.

  1. Find the owner. Read it from the running app (kubectl exec … -- id / ls -ln) or, if nothing mounts the PVC, from a throwaway inspection pod. The step-by-step recipe — including 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) — lives in the Permissions guide → Find the UID/GID.
  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 resolved identity is recorded on every snapshot

Whichever way you express it, the resolved identity each backup actually ran as (uid/gid, pod fsGroup, and whether it was inherited, explicit, or a default) is recorded on the kopia snapshot itself as the kopiur-meta tag and mirrored to the Snapshot's status.recorded — durable in the repository, so it survives a cluster rebuild and re-appears on discovered rows. See Backups → tags.

Three ways to set the context

1. Set it explicitly

The most direct: hard-code the UID/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:

# 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 — two layers:
#   * `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).
#
# This example sets both: a repository-wide default (1000:1000 + fsGroup 1000) and
# a per-recipe tweak, 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.
  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 — reads any file
    #   runAsNonRoot: false
    # privilegedMode: true     # also preserves UID/GID ownership on RESTORE

2. Inherit it from the workload

If you'd rather "run as whatever the app runs as" than track a UID, inheritSecurityContextFrom copies the security context from a live workload pod onto the mover — both the container securityContext (UID/GID) and the pod-level securityContext (e.g. fsGroup). This is the answer to "back up / restore as the pod that mounts this PVC," at both levels. It is an externally-tagged choice — pick exactly one form (two read a live pod; the third, restore-only snapshot, replays the identity recorded on the backup and needs no pod at all):

On a backup, Kopiur can find the pod that mounts the source PVC for you and inherit its security context — no selector to write or 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 (excluding Kopiur's own mover pods), prefers a Running one, and copies its container + pod securityContext onto the mover. If no workload pod currently mounts the PVC (e.g. it's scaled to zero), the Backup is held with an actionable condition — scale the workload up. If you'd 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).

Your workload must pin runAsUser for this to do anything

Inheriting copies the workload's pod spec fields. It cannot see the UID baked into the workload's image (its USER line). If the workload pins no runAsUser at either the container or the pod level — a securityContext block that only sets allowPrivilegeEscalation/capabilities counts as pinning nothing — then there is no UID to inherit, and the mover falls back to its own image's UID 65532 — after which the backup typically fails with permission denied. Kopiur reports this as SecurityContextInherited=False / InheritPinnedNoUid plus a Warning Event naming the pod, rather than letting the run look correctly configured.

Check before relying on it:

$ kubectl -n app get pod <consumer> \
    -o jsonpath='{.spec.securityContext}{"\n"}{range .spec.containers[*]}{.name}{" "}{.securityContext}{"\n"}{end}'

If no runAsUser appears, set one on the workload, or set mover.securityContext.runAsUser to the image's UID (the two combine — see below).

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 there. (The admission webhook rejects pvcConsumer on a Restore.)

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 (inherit from the pod that will read the restored data), or on a backup when you'd 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-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. If no pod matches, the selector is empty, the named container is absent, or the pod sets neither a container nor a pod-level securityContext, the Backup/Restore is held with an actionable MissingDependency-style condition telling you exactly what to fix — unless the recipe also sets a mover.securityContext pinning a runAsUser, which is then used as the fallback (InheritFallback) and the run proceeds. The matched workload must be running so its identity can be read.

Things to remember:

  • Inheriting a root workload is still elevated. The resolved contexts are what's evaluated — container and pod — so inheriting from a pod that runs as root (or with runAsUser: 0 at either level, or added capabilities) trips the privileged-mover gate exactly like an explicit root context would.
  • Inheriting root just works — 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, since runAsNonRoot: true + 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)

Every backup records the uid/gid/fsGroup its mover actually ran as — on the kopia snapshot itself (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: {} } (it is the one inherit mode allowed with a populator target). 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 — so a purely declarative re-bootstrap works: apply Repository + SnapshotPolicy + Restore and let the catalog scan materialize the rows the search needs.

If no recorded identity is resolvable yet — the CR is missing, it carries no status.recorded (a pre-feature or foreign backup), or the catalog scan hasn't landed — the Restore holds with SecurityContextInherited=False / MissingRecordedIdentity and re-checks every few minutes; 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 (InheritFallback) exactly as with the live-pod modes, and explicit fields override recorded ones the same way (recorded ⊂ explicit).

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 the provenance (inherited = it tracked the workload at backup time; explicit/defaults = 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 — so its mover's identity was image-determined and there is nothing to replay; 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: {}.

Write snapshot: {}, not snapshot:

A bare snapshot: is YAML null, not an empty object, and admission rejects it (the variant is a sub-object so future knobs can slot in). Always write snapshot: {}.

Recorded metadata is untrusted repository data

The kopiur-meta tag lives in the repository: anyone with repository write credentials — a NAS admin, a peer cluster sharing the repo, a compromised replication target — can forge {"schema":1,"uid":0} on any snapshot. That is a different (and usually weaker) trust domain than the RBAC needed to run a root pod in your namespace. Kopiur's mitigations:

  • A recorded root identity trips the privileged-mover gate exactly like live-pod inherit — nothing runs as uid 0 until the namespace opts in via the privileged-movers annotation. Root-via-recorded-metadata deliberately needs no stronger opt-in: the gate expresses namespace-scoped intent to run privileged movers, whatever the identity source.
  • The condition and Events always name the recorded uid, its provenance, and the Snapshot it came from — so a forged uid-0 tag stays auditable even in namespaces already annotated for privileged movers.

snapshot is restore-only: a backup's identity comes from the live workload (pvcConsumer/workloadSelector) — it 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 + 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/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

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, and the mover's identityrunAsUser/runAsGroup — always belongs to the highest layer that pins one, regardless of which level it was written at. 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 — container beats 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 (runAsNonRoot, seccompProfile, …) merge field-wise 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.

An explicit field is an override, not a default

Because explicit wins, runAsUser: 1000 above pins the mover to 1000 always — inheritance never gets a say on that field, even when the workload is running as something else. There is deliberately no way to express "prefer the workload's UID, else 1000": one field, one rule.

If that's not what you meant, leave runAsUser out and let inherit supply it. Kopiur raises SecurityContextInherited=False / InheritOverridden naming both UIDs when your explicit UID displaces a resolved one, so this can't silently drift.

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.

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, selector matches nothing). Your explicit context stood in, so the run proceeded rather than being held. Nothing, if that's the intent. Otherwise scale the workload up.
False / InheritPinnedNoUid A pod resolved, but pins no runAsUser and no group beyond the mover's own defaults — its identity is in its image, which Kopiur cannot read. 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. 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 (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 — the target is a fresh read-write volume the kubelet does apply fsGroup to — so 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 (gated) root mover. 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 (SnapshotPolicy + 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: 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
---
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
---
# ── 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 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

3. Go root (privileged mover)

When the data is owned by assorted UIDs you can't match (a lost+found, a multi-user volume, an app that writes as root), a root mover reads everything — and on restore it can reproduce original ownership. It is elevated and gated (see below):

spec:
  mover:
    securityContext:
      runAsUser: 0
      runAsNonRoot: false
    privilegedMode: true # also preserves UID/GID ownership on RESTORE

Prefer matching the UID over going root

A root mover widens the blast radius of the minted mover ServiceAccount. Reach for it only when you genuinely can't match the owning UID/GID. Most single-app PVCs back up fine as their app's UID.

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've set an ignoreFileErrors/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 (no shared UID or group), the webhook attaches a non-blocking warning to the apply. 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's never a guess:

  3. True — provably fine, checked against the resolved mover identity and the live workloads mounting the source: either the mover is root, 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 so only once it has confirmed the UIDs match.
  4. False — set only by the certain post-run signal (#3): 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.
  5. Absent — the common case: not provable from the spec alone (nobody pinned a UID, several UIDs write the volume, …). Absence is not a warning; it means "no claim", and the run proceeds.
$ kubectl get snapshot pg-backup -o jsonpath='{.status.conditions[?(@.type=="SecurityContextCompatible")]}'

A Restore carries the analogous RestoreSecurityContextCompatible condition, which is positive-only (True when the future consumer can read what the mover writes — 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.

  1. From kopia's own output (the authoritative signal). The mover doesn't re-walk the tree — 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 + 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.
$ 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 being inheritSecurityContextFrom.pvcConsumer: {} (above), or a matching runAsUser/fsGroup.

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

…and it evaluates the resolved context, so an inherited-from-root mover counts too. If the namespace hasn't opted in, the Backup/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/permissions.md, docs/security-context.md)
#
# 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"
$ kubectl apply -f privileged-mover-namespace.yaml

…or 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/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

Preserving original ownership on restore

kopia records each file's original UID/GID in the snapshot. An unprivileged restore mover writes everything owned by its own UID instead — fine when one UID owns everything, 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:

spec:
  mover:
    securityContext: { runAsUser: 0, runAsNonRoot: false }
    privilegedMode: true

This is the same elevation the gate covers, so the restore namespace must opt in. There's an inherent trade: "preserve arbitrary ownership exactly" and "run unprivileged" are largely mutually exclusive.

ReadWriteMany / multi-writer volumes

fsGroup ownership-remapping (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 RWX, match the owning UID/GID directly via the container securityContext, or — if the volume holds files from several UIDs — use a root mover to read/write regardless of owner.

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 is the pragmatic answer; pair it with privilegedMode: true if you also need restores to land with the original ownership.

NFS sources and filesystem repositories

  • NFS exports often apply root_squash (root is remapped 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/mover UID. That's not a securityContext knob — see Permissions → Filesystem repositories.

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, e.g. 1000) and write the repo backend (which lives on an NFS export owned by some dedicated UID/GID, e.g. 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, 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 + setgid (so new repo blobs inherit the GID):

    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 (the bootstrap connect/create Job, every snapshot/maintenance mover, and the kopia-ui server) must carry the shared group:

    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/Restore reads as the app's identity, e.g. via 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:

# 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

Alternatives

  • runAsUser matching the export owner also works (it changes the actual process UID, unlike fsGroup) — but if the source PVC is owned by a different UID, the mover then can't read it, which is why the shared-group split is usually better.
  • Server-side remap (TrueNAS Mapall User/Group, or all_squash/anonuid on a Linux exporter) makes every client write land as one identity on the server, regardless of the pod's UID. Zero pod-side config; the admission warning is then a false positive you can ignore.

Restricted namespaces (Pod Security Admission)

The hardened default satisfies the restricted PSA profile, so unprivileged movers run anywhere. A root/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

Prove inherit-from-the-workload end to end: 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.

One apply-ready bundle, deploy/examples/tryit/inherit-security-context.yaml: the app Namespace, a PVC, a long-running app Deployment (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 (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:

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

$ 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):

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

# 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

Illustrative names

app-data-manual-abc12 and <mover-pod> stand in for the server-generated names your run gets. Substitute the names kubectl create / kubectl get pods print. The matching 1000 on both sides is the point — inherit copied the workload's identity.

Scale the workload to zero and inherit fails loudly

kubectl -n app scale deploy/app --replicas=0, then re-create the Snapshot: with no Running pod to read, the Snapshot is held with an actionable condition telling you exactly that — inherit is selection of a live pod, not a stored value. Scale back up and it proceeds.

That's the behavior when the recipe has nothing else to go on, as here. Add a mover.securityContext that pins a runAsUser and the same scale-to-zero instead proceeds on that context, reporting SecurityContextInherited=False / InheritFallback — see Combining inherit with an explicit context. Holding is the default precisely because a wrong-UID backup is worse than a missing one; you opt out of it by writing the identity you want used instead.

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
Inherit from workload SnapshotPolicy.spec.mover.inheritSecurityContextFrom (pvcConsumer/workloadSelector) Restore.spec.mover.inheritSecurityContextFrom (workloadSelector, or snapshot: {} — the backup's recorded identity, no live pod)
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

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/bytes is the classic sign the mover couldn't read the source — recheck the UID. The full verification workflow (status conditions, what a healthy run looks like) is in Permissions → Verify it worked.

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 for restore ownership)
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