Skip to content

Copy methods: Snapshot, Clone, Direct

SnapshotPolicy.spec.copyMethod chooses how Kopiur captures your data before kopia reads it. Kopia always backs up files from a mounted volume — the copy method only decides which volume the backup mover mounts:

Method What the mover reads Point-in-time? Decoupled from the app's node? Requires
Snapshot (default) A temporary PVC restored from a CSI VolumeSnapshot of your source ✅ yes ✅ yes CSI snapshot stack + a VolumeSnapshotClass
Clone A temporary CSI clone of your source PVC ✅ yes (at clone time) ✅ yes CSI driver with volume-clone support
Direct Your live source PVC, read-only ❌ no (crash-consistent live read) ❌ no (co-located with the app) Nothing — works on any storage

Which should I use?

Do you have (or can you install) the CSI snapshot stack for this source?
├─ Yes  ─────────────────────────────────────────────►  Snapshot   (default, preferred)
│         (crash-consistent, point-in-time, decoupled from the app's node)
│         Only volume cloning, not snapshots?  ────►  Clone
└─ No — no CSI snapshot support, or a static/hostPath/non-CSI source  ─►  Direct
          (config, media, file shares; simplest; works everywhere; set explicitly)
  • Start with Snapshot (the default) — crash-consistent, point-in-time, and decoupled from the node your app runs on. Best for databases and anything you don't want tied to app placement.
  • Set copyMethod: Direct explicitly if you don't have (or don't want to maintain) the CSI snapshot stack, or the source is a static/non-CSI volume (hostPath, some NFS setups) — it works on any storage, no CSI required.
  • Use Clone only if your driver does cloning but not snapshots (uncommon).

Snapshot is the default

copyMethod defaults to Snapshot because it is crash-consistent: kopia reads a frozen point-in-time capture instead of a live, possibly-mid-write PVC — the difference matters most for databases and other stateful apps. It requires the CSI snapshot stack (external-snapshotter + a VolumeSnapshotClass for your source's driver). If your cluster doesn't have it, or the source is a static/non-CSI volume, set copyMethod: Direct explicitly. When the stack/class is missing and copyMethod was left at its default, the backup fails with a clear condition telling you exactly what to install or which field to set — it never silently falls back to a live read.

Upgrading? Two hazards when copyMethod is left implicit

copyMethod began defaulting to Snapshot as of this release (previously Direct). Check any SnapshotPolicy that never sets copyMethod explicitly:

  1. No CSI snapshot stack for that source? It now fails instead of silently reading the live PVC — pin copyMethod: Direct on that policy.
  2. Server-side re-defaulting: a server-defaulted field has no field owner under server-side apply. Re-applying an existing manifest that omits copyMethod can silently flip a stored Direct value to Snapshot once the CRD is upgraded — pin copyMethod explicitly on every SnapshotPolicy you manage, especially ones reconciled by GitOps. This also applies to manifests previously produced by kopiur-migrate: translations run before this release omit copyMethod when the source VolSync object had none set, so they're exposed to this hazard too — re-run the migration (now emits an explicit value) or add copyMethod: Direct by hand before re-applying.

Try it end-to-end

See copyMethod: Snapshot actually stage a CSI copy: the bundle deploy/examples/tryit/copy-methods.yaml is self-contained — namespace, a filesystem Repository on a PVC, a CSI-provisioned app-data PVC, a seed Job, a copyMethod: Snapshot policy, and a fixed-name Snapshot (app-data-snapshot) so the staged PVC is deterministically app-data-snapshot-src.

The load-bearing line is copyMethod: Snapshot on the policy — it tells the mover to read a staged CSI copy instead of the live volume:

apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: app-data
  namespace: kopiur-tryit
spec:
  repository:
    name: primary
  # The load-bearing line: stage a CSI VolumeSnapshot instead of reading live.
  copyMethod: Snapshot
  # volumeSnapshotClassName: csi-...   # optional — omit to use the driver default
  sources:
    - pvc:
        name: app-data
  retention:
    keepDaily: 7
    keepWeekly: 4

Prerequisite: this bundle needs the CSI snapshot stack

copyMethod: Snapshot snapshots the source PVC, so the source must be CSI-provisioned and the cluster must have the external-snapshotter (the snapshot-controller + VolumeSnapshot/VolumeSnapshotContent/VolumeSnapshotClass CRDs) and a VolumeSnapshotClass whose driver matches your source's StorageClass provisioner. Fill in both REPLACE_ME values: storageClassName (a CSI class that supports snapshots) and KOPIA_PASSWORD. Leave volumeSnapshotClassName unset to auto-pick the driver's default class.

1. Apply and wait for the prerequisites.

$ kubectl apply -f deploy/examples/tryit/copy-methods.yaml
$ kubectl -n kopiur-tryit wait --for=condition=complete job/seed-data --timeout=2m
$ kubectl -n kopiur-tryit wait --for=condition=Ready repository/primary --timeout=2m

2. While the backup is Running, catch the staged PVC. The mover reads app-data-snapshot-src, not the live app-data. Fetch it by name (not a label) while the snapshot is in flight:

$ kubectl -n kopiur-tryit get snapshot app-data-snapshot -w &
$ kubectl -n kopiur-tryit get pvc app-data-snapshot-src
NAME                    STATUS   VOLUME   CAPACITY   ACCESS MODES   AGE
app-data-snapshot-src   Bound    pvc-..   1Gi        RWO            6s

3. After success, read status.staged (deep). Wait for the terminal phase, then confirm the run staged a CSI copy and which PVC the mover mounted:

$ kubectl -n kopiur-tryit wait --for=jsonpath='{.status.phase}'=Succeeded \
    snapshot/app-data-snapshot --timeout=5m
$ kubectl -n kopiur-tryit get snapshot app-data-snapshot \
    -o jsonpath='{.status.staged}'
{"copyMethod":"Snapshot","volumeSnapshotName":"app-data-snapshot-...","pvcName":"app-data-snapshot-src","ready":true,"storageClassName":"...","stagingTimeoutSeconds":600}

(Illustrative: volumeSnapshotName is generated and storageClassName is your class; the rest is exact.)

4. Confirm the stage was reaped and the live PVC was never mounted. Kopiur cleans up the staged objects on completion:

$ kubectl -n kopiur-tryit get pvc app-data-snapshot-src
Error from server (NotFound): persistentvolumeclaims "app-data-snapshot-src" not found

The live app-data PVC was never mounted by the mover — only the staged copy was. To tear down: kubectl delete namespace kopiur-tryit.


Snapshot — point-in-time CSI snapshot (default)

When a backup runs, Kopiur:

  1. Creates a CSI VolumeSnapshot of your source PVC (after any beforeSnapshot hooks, so a quiesced app yields a consistent capture).
  2. Waits for the snapshot to become readyToUse — bounded by the staging deadline (spec.staging.timeout, default 10m).
  3. Provisions a temporary staged PVC from the snapshot.
  4. Runs the kopia mover against the staged PVC — never the live volume.
  5. Cleans everything up (staged PVC + VolumeSnapshot) when the backup finishes.

The staged PVC is brand-new and unheld, so the backup mover schedules freely — it is fully decoupled from the node your application runs on (unlike Direct, which must co-locate).

What it requires

Snapshot needs the cluster's CSI snapshot stack, which your cluster administrator installs once:

  • The external-snapshotter — the snapshot-controller Deployment and the VolumeSnapshot/VolumeSnapshotContent/VolumeSnapshotClass CRDs (see the kubernetes-csi external-snapshotter docs). Many managed distributions (EKS, GKE, AKS, Talos, k3s add-ons) ship or offer this.
  • A VolumeSnapshotClass whose driver matches the CSI provisioner of your source PVC's StorageClass.

If your distribution does not bundle a snapshot-controller, the home-operations snapshot-controller chart installs the controller and the snapshot CRDs (vendored byte-for-byte from the upstream external-snapshotter release):

helm install snapshot-controller oci://ghcr.io/home-operations/charts/snapshot-controller \
  --namespace kube-system

The same chart can create the VolumeSnapshotClass for you: add an entry under its volumeSnapshotClasses value naming the driver for your source's storage (optionally annotated snapshot.storage.kubernetes.io/is-default-class: "true"), so the whole prerequisite lands in one install. Skip the chart on a distribution that already runs a controller (EKS, GKE, AKS, Talos, k3s add-ons); a second one just contends for the same CRs.

Helm never upgrades the snapshot CRDs

Like Kopiur's own chart, this one ships the CRDs in its crds/ directory: helm install creates them, but helm upgrade leaves them untouched. After bumping the chart across an appVersion, reapply the matching CRDs yourself:

helm show crds oci://ghcr.io/home-operations/charts/snapshot-controller | kubectl apply --server-side -f -

If any of this is missing, the backup fails with a clear condition telling you exactly what to do — Kopiur never silently downgrades a Snapshot backup to a live read. See Troubleshooting below.

Choosing the VolumeSnapshotClass

spec:
    copyMethod: Snapshot
    # Optional. Leave unset to auto-select your driver's DEFAULT class.
    volumeSnapshotClassName: csi-rbd-snapclass
  • Set it explicitly to pin a specific class.
  • Leave it unset and Kopiur picks the default VolumeSnapshotClass for your source's driver (the one annotated snapshot.storage.kubernetes.io/is-default-class: "true"). If exactly one class exists for the driver it's used even without the annotation.
  • If no class matches your driver, or several match with no single default, the backup fails asking you to create/annotate a class or name one explicitly.

How long staging may wait (spec.staging.timeout)

Staging has a deadline budget that bounds each of its phases:

  1. The VolumeSnapshot becoming readyToUse — measured from the VolumeSnapshot's creation.
  2. The staged PVC binding — a fresh budget measured from the staged PVC's creation. On an Immediate-binding StorageClass the CSI restore/clone runs at provision time and Kopiur waits for Bound before creating the mover Job (so a slow restore can never strand an unschedulable mover, and the VolumeSnapshot is never torn down under an in-flight restore). On a WaitForFirstConsumer class the bind happens when the mover pod schedules — Kopiur then keeps watching the staged PVC while the Job runs and fails the backup with the same reason if the bind blows the budget.
spec:
    copyMethod: Snapshot
    staging:
        # Go-style duration; default 10m. "0" waits indefinitely.
        timeout: 30m
  • Default 10m — plenty for drivers that cut snapshots in seconds (most local/on-cluster CSI), and bounded so a broken driver can't hold a Snapshot Pending forever and silently starve a concurrencyPolicy: Forbid schedule.
  • Raise it for backends whose snapshots or restores take long — e.g. cloud snapshots of large volumes (the first EBS snapshot of a big volume can take well over 10 minutes), or a CephFS full-clone restore of a small-file-heavy volume (see staging overrides for the shallow-clone alternative that makes it near-instant instead).
  • timeout: "0" waits indefinitely (never fails on the deadline).

Only this deadline fails staging. If it expires the backup goes Failed with reason VolumeSnapshotFailed (the snapshot was still reporting an error), StagingTimedOut (no error — the driver/snapshot-controller is stuck), or StagedPvcBindTimeout (the snapshot was fine but the staged PVC never bound — the restore/clone is still provisioning or can't provision), and the message names this field. A Failed backup is terminal; the next scheduled run (or a new Snapshot) retries — and Kopiur reaps whatever staging objects the failed run already created.

A VolumeSnapshot error during the wait is NOT a failure

The snapshot-controller routinely reports transient errors on a perfectly healthy VolumeSnapshot — most commonly a benign 409 Conflict ("the object has been modified; please apply your changes to the latest version and try again") while it adds finalizers, which its own retry clears a moment later. Kopiur surfaces such errors on the SourceStaged condition for visibility but keeps waiting; it declares VolumeSnapshotFailed only if the snapshot is still not readyToUse when the staging deadline passes.

# Example 21 — copyMethod: Snapshot (point-in-time CSI VolumeSnapshot)
#
# THE DEFAULT copyMethod (shown explicitly here for clarity) — recommended for
# stateful apps whenever you have the CSI snapshot stack. Kopiur takes a CSI
# VolumeSnapshot of the source PVC, restores
# it into a temporary staged PVC, and runs the mover against that stage — never the live
# volume. The backup is
# point-in-time and fully decoupled from the node your app runs on; the staged
# objects are cleaned up automatically when the backup finishes.
#
# REQUIRES the cluster CSI snapshot stack (external-snapshotter + CRDs) and a
# VolumeSnapshotClass whose driver matches the source PVC's StorageClass provisioner.
# Without it the backup fails with a clear, actionable condition (it never silently
# falls back). See docs/copy-methods.md.
#
# Leave volumeSnapshotClassName UNSET to auto-select your driver's default class;
# set it (as below) to pin a specific one.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: postgres
  namespace: databases
spec:
  repository:
    name: primary
  copyMethod: Snapshot
  volumeSnapshotClassName: csi-rbd-snapclass # optional; omit to use the driver default
  # Optional: the staging deadline budget (default 10m; "0" waits indefinitely).
  # Bounds the VolumeSnapshot becoming readyToUse AND (a fresh budget) the staged
  # PVC's bind — the CSI restore window. Transient CSI/snapshot-controller errors
  # during the wait are retried — only this deadline fails staging. Raise it for
  # backends whose snapshots or restores are slow (e.g. cloud snapshots of large
  # volumes). `staging` also takes storageClassName/accessModes overrides for the
  # staged PVC — see example 30 (CephFS backingSnapshot shallow staging).
  staging:
    timeout: 10m
  sources:
    - pvc:
        name: postgres-data
  retention:
    keepDaily: 14
    keepWeekly: 8
  # For application consistency, quiesce the database before the snapshot is taken
  # (the VolumeSnapshot is captured AFTER beforeSnapshot hooks).
  hooks:
    beforeSnapshot:
      - workloadExec:
          podSelector:
            matchLabels:
              app.kubernetes.io/name: postgres
          command: ["/bin/sh", "-c", "psql -c 'CHECKPOINT;'"]
          continueOnFailure: false

Clone — CSI volume clone

Clone provisions the staged PVC directly from your source PVC (dataSource: PersistentVolumeClaim) — a CSI volume clone — with no intermediate VolumeSnapshot. Like Snapshot, the mover reads the clone and the clone is cleaned up afterward.

Use it when your CSI driver supports cloning (CLONE_VOLUME) but not snapshots. It needs no VolumeSnapshotClass.

# Example 22 — copyMethod: Clone (CSI volume clone)
#
# Stages the backup from a CSI CLONE of the source PVC (dataSource: the source PVC
# itself) instead of a VolumeSnapshot. Use this when your CSI driver supports volume
# cloning (CLONE_VOLUME) but not snapshots. Like Snapshot, the mover reads the clone
# and the clone is cleaned up afterward — no VolumeSnapshotClass is needed.
#
# If the driver can't clone the volume, the staged PVC stays Pending and the backup
# never starts — check `kubectl describe pvc <snapshot-name>-src` and switch to
# Snapshot or Direct. See docs/copy-methods.md.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: app-data
  namespace: apps
spec:
  repository:
    name: primary
  copyMethod: Clone
  sources:
    - pvc:
        name: app-data
  retention:
    keepDaily: 7

Clone requires driver support

If your driver can't clone the volume, the staged PVC stays Pending and the backup fails with StagedPvcBindTimeout once the staging deadline passes. If you see that, check the staged PVC's events (kubectl describe pvc <snapshot-name>-src) and use Snapshot or Direct instead.


Staging overrides

By default the staged PVC copies its storageClassName and accessModes from the source PVC. Two optional spec.staging fields override that — for the staged PVC only; your application's PVC is never touched:

spec:
    copyMethod: Snapshot   # overrides apply to Snapshot AND Clone
    staging:
        storageClassName: cephfs-backingsnapshot  # class for the STAGED PVC
        accessModes: [ReadOnlyMany]                # modes for the STAGED PVC
  • storageClassName — stage on a different class of the same CSI driver, typically one with different restore parameters. Kopiur verifies the driver matches up front and fails fast with StagedClassMismatch if it doesn't (a foreign driver can never provision from your source's snapshot — without the check you'd get an opaque bind timeout instead).
  • accessModes — request different modes for the stage (e.g. [ReadOnlyMany] for a snapshot-backed read-only class). The mover mounts the staged source read-only unless the source sets readOnly: false — and [ReadOnlyMany] is rejected together with that, since a read-only stage cannot be mounted read-write.
  • Both are meaningless without a staged PVC, so they're rejected at admission for copyMethod: Direct, NFS sources, and pvcSelector sources.

The flagship use: CephFS shallow snapshots

On CephFS, restoring a VolumeSnapshot into a staged PVC is a full subvolume clone — an MDS-metadata-bound copy of every file. For a volume with many small files (a git server's loose objects, maildirs), that clone can take many minutes even at ~100 MB, blowing the staging budget on setup. ceph-csi's answer is a StorageClass with backingSnapshot: "true": restore-from-snapshot then mounts the snapshot shallowly — metadata-only, read-only, ready in seconds. Point spec.staging.storageClassName at such a class and the whole problem disappears:

# Example 30 — CephFS shallow snapshot staging (spec.staging.storageClassName)
#
# copyMethod: Snapshot normally restores the CSI VolumeSnapshot into a staged PVC
# on the SOURCE's StorageClass. On CephFS that restore is a FULL subvolume clone —
# metadata-bound, so a volume with many small files (git servers, maildirs,
# node_modules-shaped data) can take many minutes to clone even at ~100 MB, and a
# slow clone burns the whole staging budget doing setup instead of backup.
#
# The fix: point the STAGED PVC (and only it — your app's PVC is untouched) at a
# rook-ceph StorageClass with `backingSnapshot: "true"`. ceph-csi then mounts the
# snapshot SHALLOWLY — a metadata-only, read-only view, ready in seconds — instead
# of copying every file. Shallow volumes are read-only by design, hence
# `accessModes: [ReadOnlyMany]`; the kopia mover always mounts the staged source
# read-only anyway, so nothing else changes.
#
# Maps to SnapshotPolicy.spec.staging.{storageClassName,accessModes}
# (crates/api/src/snapshot_policy.rs::StagingSpec). Both fields apply to
# copyMethod Snapshot AND Clone; absent they inherit from the source PVC.
# The override class MUST belong to the same CSI driver as the source — kopiur
# fails fast with `StagedClassMismatch` if it doesn't.
#
# Requires: rook-ceph / ceph-csi with CephFS (shallow volumes need ceph-csi >= 3.7)
# and the CSI snapshot stack. Adjust clusterID/fsName/secret names to your rook
# install (these are the rook-ceph defaults).
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: cephfs-backingsnapshot
provisioner: rook-ceph.cephfs.csi.ceph.com # SAME driver as your source PVC's class
parameters:
  clusterID: rook-ceph
  fsName: ceph-filesystem
  # The load-bearing line: restore-from-snapshot mounts the snapshot shallowly
  # (metadata-only, read-only, near-instant) instead of a full subvolume clone.
  backingSnapshot: "true"
  csi.storage.k8s.io/provisioner-secret-name: rook-csi-cephfs-provisioner
  csi.storage.k8s.io/provisioner-secret-namespace: rook-ceph
  csi.storage.k8s.io/controller-expand-secret-name: rook-csi-cephfs-provisioner
  csi.storage.k8s.io/controller-expand-secret-namespace: rook-ceph
  csi.storage.k8s.io/node-stage-secret-name: rook-csi-cephfs-node
  csi.storage.k8s.io/node-stage-secret-namespace: rook-ceph
reclaimPolicy: Delete
allowVolumeExpansion: false
volumeBindingMode: Immediate
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: forgejo
  namespace: forgejo
spec:
  repository:
    name: primary # an existing Repository/ClusterRepository (see examples 01-03)
  copyMethod: Snapshot
  staging:
    # Stage on the shallow class above instead of the source's own class.
    storageClassName: cephfs-backingsnapshot
    # Shallow CephFS volumes are read-only; request them as ReadOnlyMany.
    accessModes: [ReadOnlyMany]
    # With a shallow mount the stage is ready in seconds, so the default 10m
    # budget is generous. Without the override, a CephFS full clone of a
    # small-file-heavy volume can need MORE than 10m — if you must full-clone,
    # raise this instead (it bounds both the VolumeSnapshot readyToUse wait and
    # the staged PVC's bind).
    timeout: 10m
  sources:
    - pvc:
        name: forgejo-data
  retention:
    keepDaily: 14
    keepWeekly: 8

Same driver, shallow-capable versions, delete order

The shallow class must use the same provisioner as your source's class (kopiur enforces this). CephFS shallow volumes need ceph-csi ≥ 3.7 and are read-only by design — request them ReadOnlyMany. ceph-csi reference-tracks the backing snapshot, and Kopiur always deletes the staged PVC before the VolumeSnapshot, so the cleanup order is safe for shallow mounts.


Direct — read the live volume (opt-in)

Direct mounts your live source PVC into the mover, read-only, and kopia reads it in place. No snapshot, no clone, no extra storage — it works on any storage, including local-path/hostPath that has no snapshot support. Set copyMethod: Direct explicitly on the SnapshotPolicy to opt in — it is no longer the default.

Because the live volume is mounted, Kopiur co-locates the mover on the node already holding the PVC (for ReadWriteOnce volumes), avoiding the Kubernetes Multi-Attach error. See Repositories → sourceColocation. A ReadWriteOncePod source is stricter: it can't be co-mounted by the mover at all while your app holds it, so use Snapshot (or Clone) for those — see PVC access modes & RWOP.

# Example 23 — copyMethod: Direct (read the live volume) — OPT-IN
#
# Direct is opt-in (copyMethod defaults to Snapshot) — set it explicitly, as here,
# when you don't have (or don't want to maintain) the CSI snapshot stack, or the
# source is a static/non-CSI volume. Mounts the LIVE source PVC into the mover
# (read-only) and kopia reads it in place — no snapshot, no clone, no extra storage.
# Works on ANY storage, including local-path / hostPath with no snapshot support.
# The backup is crash-consistent (a live read), which is fine for config, media, and
# file shares.
#
# For ReadWriteOnce volumes Kopiur automatically co-locates the mover on the node
# already holding the PVC, avoiding the Kubernetes "Multi-Attach error" — no extra
# config needed (see docs/repositories.md#sourcecolocation-avoid-the-rwo-multi-attach-error).
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: media
  namespace: media
spec:
  repository:
    name: primary
  copyMethod: Direct
  sources:
    - pvc:
        name: media-library
  retention:
    keepDaily: 7
    keepWeekly: 4

Direct reads a live filesystem, so the backup is crash-consistent — fine for most file data, but for a busy database prefer Snapshot, or quiesce the app with hooks (below).


Making fsGroup apply to the source

By default the mover mounts the source read-only — kopia only ever reads it. That default has one surprising consequence: fsGroup does nothing on the source.

fsGroup is not a passive grant. The kubelet implements it by walking the volume and rewriting it: chgrp every file to the group, chmod g+rw, setgid on directories. That rewrite is the whole mechanism — and the kubelet skips it entirely on a read-only mount. So a mover podSecurityContext.fsGroup (or fsGroupChangePolicy) has no effect on a backup source, no matter what you set it to.

That matters when the mover must run as a specific uid:gid for reasons of its own — a non-ID-squashed NFS repository export, say — and the source PVC's files are owned by someone else. Set readOnly: false on the source and the kubelet does the walk:

spec:
  copyMethod: Snapshot        # the stage is what gets rewritten — see below
  mover:
    podSecurityContext:
      fsGroup: 1000
      fsGroupChangePolicy: OnRootMismatch
  sources:
    - pvc: { name: app-data }
      readOnly: false

What gets rewritten depends entirely on copyMethod:

copyMethod The mover mounts readOnly: false rewrites
Snapshot / Clone a temporary staged PVC the throwaway stage, deleted when the run ends. Your volume is never touched.
Direct your live PVC your production data — permanently, while the app runs.

So under Snapshot/Clone this is free, and it is the combination to reach for:

# Example 31 — make fsGroup apply to the backup source (sources[].readOnly: false)
#
# WHY THIS EXISTS: fsGroup is not a passive permission grant. The kubelet implements
# it by walking the volume and rewriting it — chgrp every file to the group, chmod
# g+rw, setgid on directories. And the kubelet SKIPS that walk entirely on a
# read-only mount. Kopiur mounts backup sources read-only by default (kopia only
# reads them), so mover.podSecurityContext.fsGroup / fsGroupChangePolicy are
# silently INERT on the source no matter what you set them to.
#
# That bites when the mover must run as a specific uid:gid for reasons of its own —
# here, a filesystem repository on an NFS export with no ID squashing, which only
# accepts writes from 1000:1000 — while the source PVC's files are owned by someone
# else. Set readOnly: false and the kubelet does the walk, normalizing group
# ownership so the mover can read.
#
# WHAT GETS REWRITTEN depends entirely on copyMethod:
#
#   Snapshot / Clone  ->  the temporary STAGED PVC. Deleted when the run ends.
#                         Your volume is never touched. (This example.)
#   Direct            ->  your LIVE production volume, permanently, while the app
#                         runs. Requires acknowledgeLiveMutation: true — see
#                         example 32.
#
# So pair readOnly: false with a staged copyMethod whenever your storage supports
# it; that is the combination with no downside.
#
# NOT SUPPORTED on an `nfs:` SOURCE: the kubelet never applies fsGroup to in-tree
# NFS volumes, so readOnly: false cannot achieve anything there and is rejected at
# admission. (The NFS below is the REPOSITORY, not the source.) Nor with
# staging.accessModes: [ReadOnlyMany] — a read-only stage cannot be mounted rw.
#
# See docs/copy-methods.md#making-fsgroup-apply-to-the-source
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
  name: nas
  namespace: media
spec:
  backend:
    filesystem:
      path: /repo
      nfs:
        server: nas.internal
        path: /export/kopia
  encryption:
    passwordSecretRef:
      name: kopia-repo
      key: KOPIA_PASSWORD
  moverDefaults:
    # The export is not ID-squashed, so every kopia process touching it must be
    # 1000:1000 or the repository writes are refused.
    securityContext:
      runAsUser: 1000
      runAsGroup: 1000
    podSecurityContext:
      fsGroup: 1000
      fsGroupChangePolicy: OnRootMismatch
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: photos
  namespace: media
spec:
  repository:
    name: nas
  # The stage is what readOnly: false rewrites — a throwaway PVC, not your data.
  copyMethod: Snapshot
  sources:
    - pvc:
        name: photos-data
      # Without this, the mount is read-only, the kubelet skips the fsGroup walk,
      # and the mover (uid 1000) cannot read files owned by the app's uid.
      readOnly: false
  retention:
    keepDaily: 7
    keepWeekly: 4

Direct + readOnly: false rewrites your live data

With Direct there is no stage: the kubelet chgrp's your running application's files to the mover's fsGroup and makes them group-writable — permanently, on the next backup. Postgres and Redis both refuse to start on an over-permissive data directory; anything asserting on group ownership can break the same way. There is no undo.

Because that is not inferable from intent — you set one flag to fix a permission error, not to re-own your data — Kopiur rejects the combination at admission unless you say so explicitly with acknowledgeLiveMutation: true.

Prefer copyMethod: Snapshot/Clone if your storage supports it. acknowledgeLiveMutation is ignored anywhere it is not needed, so it is safe to leave in place if you switch back.

The acknowledged form, for storage with no CSI snapshot support:

# Example 32 — copyMethod: Direct + readOnly: false — REWRITES YOUR LIVE VOLUME
#
# READ THIS BEFORE COPYING. Example 31 is almost certainly what you want instead.
#
# readOnly: false asks the kubelet to apply fsGroup to whatever the mover mounts.
# Under copyMethod: Snapshot/Clone that is a throwaway staged PVC and it costs you
# nothing. Under Direct there IS no stage — the mover mounts your live PVC, so the
# kubelet recursively chgrp's YOUR RUNNING APPLICATION'S FILES to fsGroup and makes
# them group-writable. Permanently. On the next backup.
#
# Postgres and Redis both refuse to start on an over-permissive data directory.
# Anything that asserts on group ownership can break the same way. There is no undo.
#
# Kopiur rejects this combination at admission unless you set
# acknowledgeLiveMutation: true, because the intent is not inferable: you would be
# setting readOnly: false to fix a permission error, not to re-own your data.
#
# ONLY use this when your storage genuinely cannot do CSI snapshots or clones (so
# copyMethod: Snapshot/Clone is unavailable) AND you have confirmed your workload
# tolerates the group-ownership change.
#
# acknowledgeLiveMutation is IGNORED anywhere it is not needed, so it is safe to
# leave in place if you later switch to copyMethod: Snapshot — no two-step edit.
#
# See docs/copy-methods.md#making-fsgroup-apply-to-the-source
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: legacy-share
  namespace: files
spec:
  repository:
    name: nas
  # No CSI snapshot stack on this storage class, so there is no stage to interpose.
  copyMethod: Direct
  mover:
    podSecurityContext:
      fsGroup: 1000
      fsGroupChangePolicy: OnRootMismatch
  sources:
    - pvc:
        name: legacy-share-data
      readOnly: false
      # "I understand the kubelet will chgrp legacy-share-data's contents to 1000
      #  and make them group-writable, and this workload tolerates that."
      acknowledgeLiveMutation: true
  retention:
    keepDaily: 7

Two more rejections you may hit, both at admission:

  • nfs sources. The kubelet does not apply fsGroup to in-tree NFS volumes at all, so readOnly: false cannot achieve anything there and only makes the export writable. Use mover.podSecurityContext.supplementalGroups / mover.securityContext.runAsUser matching the export's ownership, or remap IDs server-side. See Security context.
  • staging.accessModes: [ReadOnlyMany] (and read-only staged classes generally, like CephFS backingSnapshot). A read-only stage cannot be mounted read-write — the kubelet would fail the mount at backup time.

Even with all this correct, whether the kubelet actually performs the walk still depends on your CSI driver: fsGroupPolicy: None skips it, and the default ReadWriteOnceWithFSType skips RWX volumes. Kopiur therefore never claims SecurityContextCompatible=True on an fsGroup basis — it reports Unknown and lets the mover's readability preflight be the arbiter at runtime.


Consistency: what each method guarantees

  • Snapshot / Clone capture a point-in-time image at the block level — crash-consistent (like a power-cut: the filesystem is intact, in-flight writes may not be flushed).
  • Direct reads files while the app may be writing — also crash-consistent, but spread across the read rather than a single instant.

For application consistency (a database flushed and quiesced), use SnapshotPolicy.spec.hooks to quiesce before the capture and resume after. With Snapshot, the VolumeSnapshot is taken after your beforeSnapshot hooks, so a FLUSH/fsfreeze hook yields a consistent snapshot. See Backups → hooks.

Cleanup & cost

Kopiur reaps the staged PVC and VolumeSnapshot when the backup reaches a terminal state (and again if you delete the Snapshot). To avoid the well-known leak where a Retain StorageClass leaves the staged PV (and its backend volume) behind, Kopiur flips a bound staged PV's reclaim policy to Delete before removing it. A Retain VolumeSnapshotClass keeps the underlying storage snapshot after the VolumeSnapshot object is deleted — prefer a Delete deletion policy for the class you point Kopiur at, unless you want to keep raw storage snapshots yourself.

status.staged on the Snapshot records what was created (the VolumeSnapshot + staged PVC names) for visibility.

Troubleshooting

If a SnapshotPolicy never sets copyMethod and the cluster has no CSI snapshot stack, the backup fails immediately on the (default) Snapshot attempt — most often with SnapshotStackMissing below. The fix in every row is the same shape: install what's missing, or pin copyMethod: Direct on the policy to opt out of CSI staging.

Condition / symptom Cause Fix
SourceStaged=False, reason SnapshotStackMissing No VolumeSnapshotClass API — the external-snapshotter isn't installed. Install the snapshot-controller and a VolumeSnapshotClass (What it requires has the snapshot-controller chart command), or set copyMethod: Direct.
SourceStaged=False, reason NoVolumeSnapshotClass No class matches your source PVC's driver (or several do with no single default). Create/annotate a VolumeSnapshotClass for the driver, set volumeSnapshotClassName explicitly, or use Direct.
SourceStaged=False, reason VolumeSnapshotFailed The VolumeSnapshot was still reporting an error when the staging deadline passed (spec.staging.timeout, default 10m) — transient errors during the wait are retried, never fatal on their own. Read the message (it includes the driver's last error); fix the class/driver, or raise spec.staging.timeout if the backend is just slow. The next scheduled run (or a new Snapshot) retries.
SourceStaged=False, reason StagingTimedOut The VolumeSnapshot never became readyToUse within the staging deadline and reported no error — the CSI driver / snapshot-controller is stuck or very slow. Check the driver and the snapshot-controller; raise spec.staging.timeout (or set it to "0" to wait indefinitely) if the backend is just slow.
SourceStaged=False, reason SourceNotCSIProvisioned The source PVC has no StorageClass (a static/hostPath volume) — nothing to snapshot. Use a CSI-provisioned PVC, or copyMethod: Direct.
SourceStaged=False, reason StagedClassNotFound spec.staging.storageClassName names a StorageClass that doesn't exist. Create the class, point the override at an existing one, or remove the override to stage on the source's class.
SourceStaged=False, reason StagedClassMismatch spec.staging.storageClassName is on a different CSI driver than the source — its provisioner can never restore/clone from your source, so the staged PVC would never bind. Point the override at a class of the source's driver (the message names both), or remove it.
SourceStaged=False, reason WaitingForStagedPvcBind (Pending, transient) The staged PVC is still binding — the CSI restore/clone from the source is provisioning. Normal for slow restores; bounded by the staging deadline. Nothing, usually — it either binds or fails at the deadline with the row below.
SourceStaged=False / phase Failed, reason StagedPvcBindTimeout The staged PVC never reached Bound within spec.staging.timeout — the restore/clone is still provisioning (e.g. a CephFS full clone of a small-file-heavy volume) or the class can't provision it. kubectl describe pvc <name>-src + the CSI provisioner logs; raise spec.staging.timeout if the copy is just slow, or (CephFS) stage on a backingSnapshot: "true" shallow class.
Phase Failed, reason StagedPvcLost The staged PVC reports Lost — its bound PV disappeared mid-stage. Check the CSI driver / PV lifecycle; the next scheduled run retries.
Backup stuck Pending, staged PVC Pending WaitForFirstConsumer (normal — binds when the mover starts) or the driver can't clone (for Clone). If it never binds, the backup fails at the staging deadline with StagedPvcBindTimeout; kubectl describe pvc <name>-src for the driver event; switch method if cloning is unsupported.

See also Troubleshooting → Multi-Attach for the Direct-mode co-location path.