Skip to content

Examples

A walkthrough of the manifests in deploy/examples/. Each is a complete, apply-ready manifest; copy one, replace the REPLACE_ME secrets and PVC/bucket names, and kubectl apply -f.

Looking for a whole workflow, not one capability?

This page is a ladder of per-capability manifests. For end-to-end, problem-driven walkthroughs — protect a database, recover deleted data, disaster recovery, cross-cluster migration, adopting an existing repo, verification drills — see Scenarios. For the full install-to-restore journey with the reasoning behind every choice (S3 and NAS tracks, CLI included), see the Complete walkthrough.

Single source

The YAML below is pulled directly from the manifests in deploy/examples/ at build time (MkDocs snippets), so the docs never drift from the files you actually apply. Each manifest carries its own inline comments.

The mental model

Kopiur separates the backup recipe (SnapshotPolicy) from its invocation (Snapshot) from its schedule (SnapshotSchedule). A SnapshotPolicy runs nothing on its own — a SnapshotSchedule (cron) or a Snapshot (manual / external trigger) is what produces a snapshot. The Repository holds the storage, and Maintenance reclaims it. You can apply a whole bundle at once; the operator resolves the ordering.

# Example Demonstrates
01 Single PVC, scheduled The canonical first backup: Repository → SnapshotPolicy → SnapshotSchedule.
02 Shared platform repository A cluster-scoped ClusterRepository tenants reference without secrets.
03 Restore by picking a Snapshot Restore is "pick a row" from the catalog — no timestamp math.
04 Multi-PVC selector Back up every PVC matching a label as one consistent group.
05 Deploy-or-restore (GitOps) One bundle that restores on a fresh cluster, backs up otherwise.
06 Manual one-shot backup A Snapshot CR as the universal trigger.
07 Restore a discovered backup Restore foreign / pre-install snapshots.
08 Maintenance A standalone Maintenance for fine-grained control.
09 Mover UID/GID & permissions Match the mover's UID/GID to the data owner so it can read it.
10 NFS source (no PVC) Back up a NAS export directly — no PersistentVolumeClaim.
11 Credential projection Let the operator copy the repo Secret into each mover namespace.
12 Restore mover, cache & failure policy Give a Restore the same UID/GID, cache, and retry knobs a backup has.
13 Restore by raw kopia identity Restore a foreign / aged-out snapshot by username@hostname:path.
14 Point-in-time / offset restore "Roll back to Tuesday 2am" — restore via asOf / offset.
15 In-place mirror restore Restore into an existing PVC and make it an exact mirror.
16 Cross-namespace clone restore Clone one namespace's snapshot into another (prod → staging).
17 Restore from a shared repo (projection) Restore from a ClusterRepository into a fresh namespace, creds projected.
18 Inherit the mover security context Run the mover as "whatever the app runs as" by selecting the workload.
19 Repository replication Mirror a repository to a second backend (the "2" in 3-2-1).
20 Quiesce with hooks Run workloadExec/httpRequest hooks around the snapshot for app-consistent backups.

Looking for a specific storage backend?

Each backend (S3, Azure, GCS, B2, filesystem, SFTP, WebDAV, rclone) has its own dedicated page — provider prerequisites, the exact Secret shape, a field-by-field reference, and a complete apply-ready manifest — starting from the Backend configuration index. The apply-ready manifests themselves live under deploy/examples/backends/. The numbered ladder below is the task/workflow tutorial.

Alpha

These use API group kopiur.home-operations.com, version v1alpha1. Backends are externally tagged (the bucket lives under backend.s3, not backend: { kind: S3 }).


Example 01 — Single PVC, scheduled

The canonical first backup: one Repository (S3), one SnapshotPolicy (the idempotent recipe), one SnapshotSchedule (the cron that creates Snapshot CRs). Maintenance is implicit — a default Maintenance is created for the repository unless you override or disable it.

# Example 01 — Single PVC, scheduled daily
#
# The canonical first backup: one Repository (S3), one SnapshotPolicy (the recipe,
# idempotent — runs nothing on its own), one SnapshotSchedule (the cron that
# creates Snapshot CRs). Maintenance is implicit: a default Maintenance is created
# on first reference to a Repository unless one already exists.
#
# Field shapes verified against crates/api (externally-tagged backend: the bucket
# lives under `backend.s3`, NOT `backend: { kind: S3 }`).
#
# The `# --8<-- [start:…]` / `# --8<-- [end:…]` lines are snippet-section markers
# (docs/backups.md pulls the policy and schedule stages individually). They are
# plain YAML comments — the file stays `kubectl apply -f`-able as-is.
---
apiVersion: v1
kind: Secret
metadata:
  name: nas-primary-creds
  namespace: billing
type: Opaque
stringData:
  # Backend auth keys are read by well-known names from the referenced Secret.
  AWS_ACCESS_KEY_ID: "REPLACE_ME"
  AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
  # Repository encryption password (kopia repo password).
  KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
  name: nas-primary
  namespace: billing
spec:
  backend:
    s3:
      bucket: my-backups
      prefix: prod/
      endpoint: s3.us-east-1.amazonaws.com
      region: us-east-1
      auth:
        secretRef:
          name: nas-primary-creds
  encryption:
    passwordSecretRef:
      name: nas-primary-creds
      key: KOPIA_PASSWORD
  create:
    enabled: true
  # Maintenance is default-managed: omit this block entirely and the operator
  # creates and owns a Maintenance for this Repository with sensible defaults
  # (quick every 6h, full daily at 03:00). Set it to tune the schedule, or
  # `enabled: false` to opt out. An externally-authored Maintenance (see
  # example 08) is always honored — the operator never duplicates it.
  maintenance:
    enabled: true
    schedule:
      quick: { cron: "0 */6 * * *", jitter: 30m }
      full: { cron: "0 3 * * *", jitter: 1h }
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: postgres-data
  namespace: billing
spec:
  # repository.kind defaults to Repository; shown explicitly for clarity.
  repository:
    kind: Repository
    name: nas-primary
  sources:
    - pvc:
        name: postgres-data
  # GFS retention is the ONLY successful-retention driver.
  retention:
    keepDaily: 14
    keepWeekly: 4
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
  name: postgres-data-nightly
  namespace: billing
spec:
  # Which recipe (SnapshotPolicy) to invoke each firing.
  policyRef:
    name: postgres-data
  schedule:
    # Jenkins-style H pins a deterministic per-schedule minute.
    cron: "H 2 * * *"
    jitter: 30m
    # GitOps-friendly default: do not fire immediately on create.
    runOnCreate: false

Example 02 — Shared platform repository

A platform team owns a cluster-scoped ClusterRepository; tenant namespaces reference it without knowing the secret name or backend details. Per-consumer identity is templated at admission.

Note

Requires the operator installed with installScope=cluster. Because ClusterRepository is cluster-scoped, its Secret references must carry an explicit namespace (webhook-enforced).

# Example 02 — Shared platform repository
#
# A platform team owns a cluster-scoped ClusterRepository; tenant namespaces
# reference it without knowing the secret name or backend details. Identity
# defaults are CEL expressions evaluated per consumer namespace (at admission,
# pinned to status).
#
# REQUIRES the operator installed with installScope=cluster (ClusterRepository is
# a cluster-scoped kind and is only reconciled in cluster scope).
#
# Note: because ClusterRepository is cluster-scoped, its Secret references MUST
# carry an explicit `namespace` (webhook-enforced).
---
apiVersion: v1
kind: Secret
metadata:
  name: kopia-platform-creds
  namespace: kopiur-system
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: ClusterRepository
metadata:
  name: shared-primary
spec:
  backend:
    s3:
      bucket: org-kopia-repo
      prefix: "" # bucket root, maximum dedup across tenants
      endpoint: s3.us-east-1.amazonaws.com
      region: us-east-1
      auth:
        secretRef:
          name: kopia-platform-creds
          namespace: kopiur-system # explicit ns required for cluster scope
  encryption:
    passwordSecretRef:
      name: kopia-platform-creds
      namespace: kopiur-system # explicit ns required for cluster scope
      key: KOPIA_PASSWORD
  # Tenancy gate (externally-tagged: list | selector | all).
  allowedNamespaces:
    list:
      - billing
      - payments
      - identity
      - media
  # CEL expressions, validated at admission, then rendered against the LIVE
  # consumer on every reconcile. The CEL environment is `namespace`,
  # `policyName`, `labels`, `annotations`, `cluster` (the consuming
  # SnapshotPolicy's metadata + this repo's `cluster` below). Each *Expr
  # returns a string.
  identityDefaults:
    hostnameExpr: "namespace"
    usernameExpr: "namespace + '-' + policyName"
    # Uncomment to share THIS repository across multiple clusters (an RFC 1123
    # label, <=32 chars, no dots): flips the no-hostnameExpr default hostname to
    # <namespace>.<cluster>, feeds the `cluster` CEL variable above, and
    # cluster-qualifies the managed Maintenance lease so clusters never fight
    # over it. See docs/scenarios/shared-repository-multi-cluster.md.
    # cluster: east
  # Maintenance is default-managed here too. Because Maintenance is namespaced
  # but a ClusterRepository is not, `namespace` selects where the managed
  # Maintenance CR lands; it defaults to the operator's own namespace
  # (KOPIUR_NAMESPACE) when omitted.
  maintenance:
    namespace: kopiur-system
  # Index-blob health. kopia's content index grows with each
  # backup and is compacted by maintenance; if maintenance stops keeping up the
  # count climbs unbounded and backups eventually degrade. The operator records
  # the count on `status.storageStats.indexBlobCount` (and an `IndexBlobs` print
  # column) and emits a Warning event + a non-blocking `IndexBlobHealth=False`
  # condition when it crosses this threshold. Optional — omit to use the default
  # of 1000; set to 0 to disable the warning. The repository stays Ready either
  # way (this is a degradation signal, not an outage). See docs/maintenance.md.
  health:
    indexBlobWarnThreshold: 1000
  # Repo-wide mover defaults inherited by every mover this repo spawns. Here we set
  # the deep-verification SCRATCH volume's size/class once, centrally — every tenant
  # SnapshotPolicy that enables `verification.deep` inherits it (and can override it
  # field-wise on its own `verification.deep`). `scratch` is the verify sibling of
  # `cache`; unlike the cache it is always ephemeral (no `mode`) and discarded after
  # each run. `storageClassName` only applies when a `capacity` is set.
  moverDefaults:
    scratch:
      capacity: 100Gi
      storageClassName: fast-ssd
---
# In the tenant namespace — no need to know the secret name or platform details.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: postgres-data
  namespace: billing
spec:
  repository:
    kind: ClusterRepository
    name: shared-primary
    # NOTE: `namespace` is forbidden here for kind=ClusterRepository (webhook).
  sources:
    - pvc:
        name: postgres-data
  retention:
    keepDaily: 14
  # Enable a weekly deep restore-test. No scratch capacity/class here — both are
  # inherited from the ClusterRepository's `moverDefaults.scratch` (100Gi/fast-ssd).
  verification:
    deep:
      schedule:
        cron: "0 5 * * 0"
        jitter: 1h
    successExpr: "stats.files > 0 && stats.errors == 0"
# Identity resolves to billing-postgres-data@billing:/pvc/postgres-data.
---
# A SECOND tenant, in a different namespace, points at the SAME ClusterRepository.
# Both apps share one deduplicated content pool, but their snapshots never collide
# because each writes under its own templated identity. This is the
# recommended layout — one shared repository, many apps — covered in the
# "How Kopia works" concepts page.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: jellyfin-config
  namespace: media
spec:
  repository:
    kind: ClusterRepository
    name: shared-primary
  sources:
    - pvc:
        name: jellyfin-config
  retention:
    keepDaily: 7
# Identity resolves to media-jellyfin-config@media:/pvc/jellyfin-config.

Example 03 — Restore by picking a Snapshot

Browse the catalog, then reference a specific Snapshot CR. No timestamp math — restore is "pick a row". source and target are externally-tagged (target.pvc creates a PVC; target.pvcRef writes into an existing one).

# list candidate snapshots for a policy, newest last:
$ kubectl get snapshots -n billing \
    -l kopiur.home-operations.com/config=postgres-data \
    --sort-by=.status.timing.startTime
# Example 03 — Restore by picking a Snapshot
#
# Browse the catalog, then reference a specific Snapshot CR. No timestamp math —
# restore is "pick a row".
#
#   # list candidate snapshots for a config, newest last:
#   kubectl get snapshots -n billing \
#     -l kopiur.home-operations.com/snapshot-policy=postgres-data \
#     --sort-by=.status.timing.startTime
#
# RestoreSource is externally-tagged: `source: { snapshotRef: {...} }`.
# RestoreTarget is externally-tagged: `target: { pvc: {...} }` (operator creates
# the PVC) or `target: { pvcRef: {...} }` (write into an existing PVC).
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: postgres-restore-yesterday
  namespace: billing
spec:
  source:
    snapshotRef:
      name: postgres-data-20260523-021300
      namespace: billing
  target:
    pvc:
      name: postgres-data-restored
      storageClassName: fast-ssd
      capacity: 100Gi
      accessModes:
        - ReadWriteOnce
  # Optional. Explicit sources fail-closed by default (onMissingSnapshot: Fail).
  policy:
    onMissingSnapshot: Fail
    waitTimeout: 5m

Example 04 — Multi-PVC selector

Back up every PVC matching a label as one consistent group (one VolumeGroupSnapshot across all matched PVCs). A Source has mutually-exclusive pvc and pvcSelector (webhook-enforced).

# Example 04 — Multi-PVC selector
#
# Back up every PVC matching a label as one consistent group. Resolves volsync's
# "one ReplicationSource = one PVC" limitation (G2).
#
# A Source has mutually-exclusive `pvc` and `pvcSelector` (webhook-enforced).
# `sourcePathStrategy` is a closed enum: `PvcName` | `PvcNamespacedName`
# (note: PvcName, not "PVCName").
# `groupBy` is a closed enum: `VolumeGroupSnapshot` (default) | `None`.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: app-bundle
  namespace: billing
spec:
  repository:
    kind: Repository
    name: nas-primary
  identity:
    username: app-bundle
    hostname: billing
  sources:
    - pvcSelector:
        labelSelector:
          matchLabels:
            backup: include
      sourcePathStrategy: PvcName
  # One consistent VolumeGroupSnapshot across all matched PVCs.
  groupBy: VolumeGroupSnapshot
  retention:
    keepDaily: 14

Example 05 — Deploy-or-restore (GitOps)

The headline GitOps pattern. Apply everything together: on a fresh cluster against an existing repo, the PVC restores the latest snapshot before the app starts; on a fresh repo, the PVC comes up empty and is backed up going forward. The trick is a passive Restore (target.populator: {}, source.fromPolicy, onMissingSnapshot: Continue) consumed by a PVC's dataSourceRef as a volume populator.

Note

The volume-populator handshake needs Kubernetes ≥ 1.24 (AnyVolumeDataSource).

# Example 05 — Deploy-or-restore, the headline GitOps pattern
#
# Apply everything together. On a FRESH cluster against an EXISTING repo, the PVC
# restores the latest snapshot before the app starts. On a fresh repo, the PVC
# comes up empty and gets backed up going forward. Same manifests either way.
#
# The pieces:
#   * SnapshotPolicy   — the recipe.
#   * SnapshotSchedule — nightly backups going forward (runOnCreate: false).
#   * Restore        — PASSIVE populator: source.fromPolicy + target.populator: {}
#                      + onMissingSnapshot: Continue. A populator Restore is consumed
#                      by a PVC's dataSourceRef as a volume populator (`target` is
#                      REQUIRED; populator intent is the explicit empty
#                      `target.populator: {}`).
#   * PVC            — references the passive Restore via spec.dataSourceRef.
#
# `onMissingSnapshot: Continue` is the DEFAULT for fromPolicy; shown explicitly.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: postgres-data
  namespace: billing
spec:
  repository:
    kind: Repository
    name: nas-primary
  sources:
    - pvc:
        name: postgres-data
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
  name: postgres-data-nightly
  namespace: billing
spec:
  policyRef:
    name: postgres-data
  schedule:
    cron: "H 2 * * *"
    jitter: 30m
    runOnCreate: false
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: postgres-data-restore
  namespace: billing
spec:
  source:
    fromPolicy:
      name: postgres-data
      offset: 0 # 0 = latest, 1 = previous, ...
  # Explicit PASSIVE populator mode: no workload target at provision
  # time — the PVC below claims this Restore via spec.dataSourceRef.
  target:
    populator: {}
  policy:
    onMissingSnapshot: Continue # default for fromPolicy — explicit for clarity
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: billing
spec:
  storageClassName: fast-ssd
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Gi
  # Volume populator handshake — needs k8s >= 1.24 (AnyVolumeDataSource).
  dataSourceRef:
    apiGroup: kopiur.home-operations.com
    kind: Restore
    name: postgres-data-restore

Example 06 — Manual one-shot backup

A Snapshot CR is the universal trigger — created by a SnapshotSchedule, by kubectl create, or by any external system (Argo Events, Tekton, CI). The trigger is separable from the recipe. deletionPolicy is Delete (default for produced) | Retain | Orphan.

# Example 06 — Manual one-shot backup
#
# A Snapshot CR is the universal trigger: created by a SnapshotSchedule, by
# `kubectl create`, or by any external system (Argo Events Sensor, Tekton Task,
# GitHub Actions, a webhook handler). Trigger is separable from recipe (G17).
#
# For scheduled/manual backups the spec carries `policyRef`. `deletionPolicy` is
# a closed enum: Delete (default for produced) | Retain | Orphan.
# `tags` are arbitrary kopia snapshot tags.
#
# The `# --8<-- [start:…]` / `# --8<-- [end:…]` lines are snippet-section markers
# (docs/backups.md pulls the trigger spec on its own). They are plain YAML
# comments — the file stays `kubectl apply -f`-able as-is.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Snapshot
metadata:
  # A fixed name here (idempotent re-apply); use `generateName: postgres-data-manual-`
  # instead to let the API server append a unique suffix for ad-hoc one-shots.
  name: postgres-data-pre-migration
  namespace: billing
spec:
  policyRef:
    name: postgres-data
  tags:
    reason: "pre-schema-migration"
  # Free-form text recorded on the kopia snapshot manifest itself
  # (`snapshot create --description`, up to 1024 characters). Per-invocation
  # by nature — scheduled/discovered Snapshots never set this.
  description: "pre-upgrade snapshot before the v14→v15 migration"
  # Keep the Snapshot CR even when the next GFS prune runs.
  deletionPolicy: Retain
  # Pin the underlying kopia snapshot so GFS retention NEVER expires it — for a
  # pre-migration / compliance hold. Clear it to release the
  # hold. `pin` exempts the snapshot from expiry; `deletionPolicy` governs what
  # happens to the snapshot when THIS CR is deleted — they're independent.
  pin: true
  # Optional per-run failure controls passed through to the mover Job (G6):
  failurePolicy:
    backoffLimit: 2
    activeDeadlineSeconds: 7200 # hard wall-clock cap on a RUNNING mover (default: 48h backstop)
    podStartupDeadlineSeconds: 300 # fail fast if the mover can't START Running within 5m
    # (CreateContainerConfigError / ImagePullBackOff / Unschedulable) instead of hanging. Default 300.

Example 07 — Restore a discovered backup

Snapshots the operator did not produce (a foreign kopia writer, or snapshots predating the install) are materialized as Snapshot CRs with origin=discovered in the repository's namespace, forced to deletionPolicy: Retain. Restore one (A) by referencing the discovered Snapshot CR, or (B) by a raw kopia identity (which requires an explicit spec.repository).

# list discovered snapshots in the repo namespace:
$ kubectl get snapshots -n backups -l kopiur.home-operations.com/origin=discovered
# Example 07 — Restore from a discovered (foreign / pre-install) backup
#
# Snapshots the operator did NOT produce (a foreign kopia writer, or snapshots
# that predate the install) are materialized as Snapshot CRs with
# origin=discovered, living in the Repository's namespace. Discovered Backups are
# forced to deletionPolicy: Retain — the operator never deletes data it did not
# create.
#
#   # list discovered snapshots in the repo namespace:
#   kubectl get snapshots -n backups -l kopiur.home-operations.com/origin=discovered
#
# Two ways to restore one:
#   A) reference the discovered Snapshot CR by name (source.snapshotRef), or
#   B) reference a raw kopia identity directly (source.identity), which needs an
#      explicit spec.repository (webhook-enforced) since there is no Snapshot CR to
#      derive it from.
---
# A) by the discovered Snapshot CR
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: rescue-restore
  namespace: billing
spec:
  source:
    snapshotRef:
      name: kopia-disc-9c2a1f
      namespace: backups
  target:
    pvc:
      name: rescue-pvc
      storageClassName: fast-ssd
      capacity: 50Gi
      accessModes:
        - ReadWriteOnce
---
# B) by raw kopia identity (foreign writer / aged-out catalog).
#    Requires spec.repository. source is externally-tagged: { identity: {...} }.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: rescue-restore-by-identity
  namespace: billing
spec:
  repository:
    kind: Repository
    name: nas-primary
    namespace: backups
  source:
    identity:
      username: postgres-data
      hostname: billing
      sourcePath: /data
      snapshotID: k1f1ec0a8
  target:
    pvcRef:
      name: rescue-pvc-existing

Example 08 — Maintenance

Maintenance is default-managed (see the Maintenance guide), but you can author a standalone Maintenance for fine-grained control — a custom ownership identity or takeover policy. When a user-authored Maintenance references a repository, the operator defers to it and never creates a duplicate.

# Example 08 — Maintenance
#
# `kopia maintenance` is a first-class concern with its own CRD and an explicit
# ownership lease (G11). At most one Maintenance owns a repository.
#
# Maintenance is default-managed: the operator creates and owns one for every
# Repository/ClusterRepository (configure it inline via `spec.maintenance` — see
# examples 01 and 02). This standalone Maintenance is the "external" path: author
# it for fine-grained control (custom ownership lease / takeoverPolicy). When a
# user-authored Maintenance like this references a repository, the operator
# defers to it and does NOT create a duplicate.
#
# `schedule.quick` / `schedule.full` are CronSpec { cron, jitter }.
# `ownership.takeoverPolicy` is a closed enum: Never (default, safest) |
# PromptCondition | Force.
# Storage actually reclaimed is surfaced ONLY here, in status.{quick,full}
# .lastContentReclaimedBytes.
#
# On-demand run (declarative): the metadata.annotations below request an
# out-of-band maintenance run the next time GitOps reconciles this manifest.
# `run-requested` is an RFC3339 timestamp (a NEW value requests a new run;
# re-applying the same value is a no-op once handled), `run-mode` is quick|full.
# The operator routes it through the same mover/lease/single-flight path as a
# scheduled slot. The imperative equivalent is `kubectl annotate maintenance …`.
#
# The `# --8<-- [start:…]` / `# --8<-- [end:…]` lines are snippet-section markers
# (docs/maintenance.md pulls this Maintenance on its own). Plain YAML comments —
# the file stays `kubectl apply -f`-able as-is.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Maintenance
metadata:
  name: nas-primary-maintenance
  namespace: billing
  annotations:
    # Request an immediate FULL run on next apply. Bump the timestamp to request
    # another; remove these to stop requesting on-demand runs (the crons keep
    # firing regardless).
    kopiur.home-operations.com/run-requested: "2026-01-01T00:00:00Z"
    kopiur.home-operations.com/run-mode: full
spec:
  repository:
    kind: Repository
    name: nas-primary
  schedule:
    quick:
      cron: "0 */6 * * *"
      jitter: 30m
    full:
      cron: "0 3 * * 0"
      jitter: 1h
    timezone: UTC
  ownership:
    owner: "kopia-operator/nas-primary"
    takeoverPolicy: PromptCondition
  mover:
    resources:
      requests:
        cpu: 250m
        memory: 1Gi
      limits:
        cpu: "2"
        memory: 4Gi
  failurePolicy:
    backoffLimit: 1
    activeDeadlineSeconds: 14400 # cap a RUNNING maintenance Job (4h)
    podStartupDeadlineSeconds: 300 # fail fast if the maintenance mover can't START in 5m (default 300)

Example 09 — Mover UID/GID & permissions

The mover Job is a separate pod that mounts and reads your PVC, so it must run as a UID/GID that can read the data (and, on restore, write the target). This SnapshotPolicy sets spec.mover.securityContext.runAsUser/runAsGroup to match the owning user, and comments the root-mover variant for data you can't match. See the Permissions guide for how to find the right numbers.

# 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

Example 10 — NFS source (no PVC)

Back up a NAS export directly: source.nfs names an NFS server + path instead of a pvc, and the operator mounts the export read-only into the backup mover for kopia to snapshot — no PersistentVolumeClaim, no StorageClass. kopia records the export path as the snapshot source path by default (override with sourcePathOverride). An NFS source is mutually exclusive with pvc/pvcSelector (webhook-enforced) and works with any repository backend. The repository here is itself an inline-NFS filesystem repo, but that's independent of the source.

# Example 10 — Back up an NFS export directly
#
# A SnapshotPolicy whose source is an inline NFS export — no PersistentVolumeClaim.
# The operator mounts the export read-only into the backup mover and kopia
# snapshots it. Reach for this to back up a NAS share (a media library, a docs
# tree) that lives outside Kubernetes and you don't want to wrap in a PVC.
#
# The repository here is itself an inline-NFS filesystem repo (see
# backends/nfs.yaml), but the source and the repository are independent: an NFS
# source works with ANY backend (S3, B2, …).
#
# sourcePath: kopia records the export's `path` as the snapshot source path by
# default (here `/mnt/eros/Media`); override it with `sourcePathOverride`. The
# export is mounted read-only at that same path inside the mover.
#
# Field shapes verified against crates/api: `source.nfs` is mutually exclusive
# with `source.pvc`/`source.pvcSelector` (webhook-enforced).
---
apiVersion: v1
kind: Secret
metadata:
  name: media-repo-creds
  namespace: media
type: Opaque
stringData:
  KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
  name: media-repo
  namespace: media
spec:
  backend:
    filesystem:
      path: /repo
      volume:
        nfs:
          server: nas.lan
          path: /export/kopia
  encryption:
    passwordSecretRef:
      name: media-repo-creds
      key: KOPIA_PASSWORD
  create:
    enabled: true
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: media-library
  namespace: media
spec:
  repository:
    name: media-repo
  sources:
    # An inline NFS export as the backup SOURCE — no PVC. Mounted read-only.
    - nfs:
        server: expanse.internal
        path: /mnt/eros/Media
      # Optional: what kopia records as the source path. Defaults to the export
      # `path` above. Uncomment to record a different identity path.
      # sourcePathOverride: /media
  retention:
    keepDaily: 7
    keepWeekly: 4
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
  name: media-library-nightly
  namespace: media
spec:
  policyRef:
    name: media-library
  schedule:
    cron: "H 4 * * *"
    jitter: 30m
    runOnCreate: false

Example 11 — Credential projection

A shared ClusterRepository keeps its credential Secret in the operator namespace, but movers run in workload namespaces and load creds via namespace-local envFrom — so normally you copy that Secret into every namespace yourself. Setting credentialProjection.enabled: true on the SnapshotPolicy (also available on Restore/Maintenance) opts out of that chore: before each run the operator projects the repository's Secret into the mover's namespace, owned by the consuming Snapshot/Restore/Maintenance (garbage-collected with it) and refreshed from source each run. It's off by default (cross-namespace copying is opt-in) but is the recommended path for a shared repository spanning several namespaces. It needs the operator's cluster-wide secrets create/patch RBAC (Helm features.credentialProjection.enabled, off by default — set it when you opt a consumer into projection); see Movers, RBAC & credentials for the security trade-off.

# Example 11 — Credential projection
#
# A shared ClusterRepository keeps its credential Secret in the operator namespace,
# but a mover Job runs in the WORKLOAD namespace and loads creds via namespace-local
# `envFrom`. By default you must place a copy of that Secret in each workload
# namespace yourself (see Example 02 + the Movers guide).
#
# Projection into a foreign namespace now needs THREE things to all line up
# (fail-closed):
#   1. The repository OWNER allows it: `credentialProjection.allowed: true` on the
#      ClusterRepository (default false — the owner of the shared creds decides).
#   2. The CONSUMER opts in: `credentialProjection.enabled: true` on the
#      SnapshotPolicy here (also available on Restore and Maintenance).
#   3. The operator has the cluster-wide `secrets` RBAC (Helm features.credentialProjection.enabled).
# Miss any one and projection does not happen.
#
# When all three line up, before each mover run the operator copies the
# repository's credential Secret(s) into the Job's namespace, owned by the
# consuming Snapshot/Restore/Maintenance (so it is garbage-collected with that CR)
# and refreshed from source every run.
#
# REQUIRES:
#   * installScope=cluster (ClusterRepository is a cluster-scoped kind).
#   * Helm `features.credentialProjection.enabled: true` — grants the operator the cluster-wide
#     `secrets` create/patch RBAC projection needs. This is OFF by default (projection
#     is opt-in), so you must set it when you opt a consumer into projection; with it
#     off, a projection-enabled consumer surfaces an actionable 403 instead.
#
# Verified against crates/api: consumer `credentialProjection` is `{ enabled: bool }`
# on SnapshotPolicySpec / RestoreSpec / MaintenanceSpec; the owner gate
# `credentialProjection.allowed: bool` lives on ClusterRepositorySpec.
---
# The shared repository's credentials — only ever placed here, in the operator
# namespace. Projection copies (never moves) them into workload namespaces.
apiVersion: v1
kind: Secret
metadata:
  name: kopia-shared-creds
  namespace: kopiur-system
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: ClusterRepository
metadata:
  name: shared-projected
spec:
  backend:
    s3:
      bucket: org-kopia-repo
      prefix: ""
      endpoint: s3.us-east-1.amazonaws.com
      region: us-east-1
      auth:
        secretRef:
          name: kopia-shared-creds
          namespace: kopiur-system # source ns (explicit ns required for cluster scope)
  encryption:
    passwordSecretRef:
      name: kopia-shared-creds
      namespace: kopiur-system # source ns the operator projects FROM
      key: KOPIA_PASSWORD
  allowedNamespaces:
    all: true
  # OWNER GATE: the repository owner must allow projection into a
  # foreign consumer namespace. Default false; a consumer's `enabled: true` is
  # necessary but NOT sufficient without this.
  credentialProjection:
    allowed: true
---
# A workload namespace that has NEVER been given a copy of kopia-shared-creds.
# The SnapshotPolicy opts into projection, so the backup still succeeds: the operator
# projects a kopiur-managed `<snapshot-name>-creds-0` Secret into `media` before the
# mover runs (one stable copy per consuming CR, refreshed each run).
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: jellyfin-config
  namespace: media
spec:
  repository:
    kind: ClusterRepository
    name: shared-projected
  sources:
    - pvc:
        name: jellyfin-config
  retention:
    keepDaily: 7
  # THE OPT-IN. Off by default; when true the operator copies the repository's
  # credential Secret(s) into THIS namespace for the backup mover, so you don't
  # have to pre-create `kopia-shared-creds` in `media`.
  credentialProjection:
    enabled: true

Example 12 — Restore mover, cache & failure policy

A Restore writes data into a PVC, so it has the same mover concerns a backup does. spec.mover matches the UID/GID that should own the restored files (or inheritSecurityContextFrom copies it from a live workload pod — the two are mutually exclusive), spec.mover.cache sizes the kopia cache (mode: Persistent keeps a warm cache PVC across runs), and spec.failurePolicy sets the restore Job's backoffLimit/activeDeadlineSeconds. An elevated restore mover (root / privilegedMode) is gated by the same per-namespace privileged-movers opt-in a backup uses. See Restores → Mover, cache & failure policy and Permissions.

# Example 12 — Restore mover controls: UID/GID, cache & failure policy
#
# A Restore writes data INTO a target PVC, so — exactly like a backup — the mover
# must run as a UID/GID that can write there, may want a sized/warm kopia cache for
# a large restore, and may want a retry/deadline policy. The `spec.mover`,
# `spec.cache` (via the mover), and `spec.failurePolicy` surfaces mirror what a
# SnapshotPolicy gives a backup.
#
# Field shapes verified against crates/api:
#   - spec.mover.securityContext       : core/v1 SecurityContext (container-level)
#   - spec.mover.inheritSecurityContextFrom : { podSelector: LabelSelector, container? }
#       (combines with securityContext: explicit fields win, inherit fills the rest)
#   - spec.mover.cache                 : { capacity, storageClassName,
#                                          metadataCacheSizeMb, contentCacheSizeMb,
#                                          mode: Ephemeral|Persistent }
#   - spec.failurePolicy               : { backoffLimit, activeDeadlineSeconds }
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: postgres-restore-tuned
  namespace: billing
spec:
  source:
    snapshotRef:
      name: postgres-data-20260523-021300
      namespace: billing
  target:
    pvc:
      name: postgres-data-restored
      storageClassName: fast-ssd
      capacity: 100Gi
      accessModes:
        - ReadWriteOnce
  # ── The mover knobs ─────────────────────────────────────────────────────────
  mover:
    # CONTAINER securityContext: run as the SAME UID/GID that owns the restored
    # files so they land with the right ownership (and the mover can write them).
    securityContext:
      runAsUser: 1000 # match the UID the app runs as
      runAsGroup: 1000 # match the GID the app runs as
      runAsNonRoot: true
      allowPrivilegeEscalation: false
      capabilities:
        drop: ["ALL"]
      seccompProfile:
        type: RuntimeDefault
    # POD securityContext: fsGroup makes a freshly-provisioned target volume
    # group-writable, so this UNPRIVILEGED (runAsUser: 1000) mover can populate a
    # brand-new PVC on restore — no root mover needed just to write the new volume.
    podSecurityContext:
      fsGroup: 1000
      fsGroupChangePolicy: OnRootMismatch # skip the recursive chown when already correct
    # Alternative to securityContext (set ONE, not both): copy the securityContext
    # from a live workload pod, so the restore always matches the app it feeds.
    # On a Restore use workloadSelector (pvcConsumer is backup-source-only):
    # inheritSecurityContextFrom:
    #   workloadSelector:
    #     podSelector:
    #       matchLabels:
    #         app.kubernetes.io/name: postgres
    #     container: postgres        # optional; defaults to the pod's first container
    #
    # ── Root mover (LAST RESORT) ──────────────────────────────────────────────
    # `privilegedMode: true` (or runAsUser: 0) is ELEVATED — the restore namespace
    # must opt in first or the Restore is refused with MoverPermitted=False:
    #   kubectl annotate namespace billing kopiur.home-operations.com/privileged-movers=true
    #
    # Cache: a large restore benefits from a sized cache. `mode: Persistent` keeps a
    # warm cache PVC across runs (ReadWriteOnce — assumes non-overlapping runs);
    # the default `Ephemeral` gives a fresh per-run volume (or emptyDir if unsized).
    cache:
      capacity: 16Gi
      storageClassName: fast-ssd
      contentCacheSizeMb: 10000 # kopia --content-cache-size-mb
      metadataCacheSizeMb: 2000 # kopia --metadata-cache-size-mb
      mode: Ephemeral
  # ── Retry / deadline for the restore Job (mirrors Snapshot.spec.failurePolicy) ──
  failurePolicy:
    backoffLimit: 4 # retry the restore Job up to 4 times
    activeDeadlineSeconds: 7200 # hard wall-clock cap (2h) for a big RUNNING restore
    podStartupDeadlineSeconds: 300 # fail fast if the restore mover can't START in 5m (default 300)
  policy:
    onMissingSnapshot: Fail
    waitTimeout: 5m

Example 13 — Restore by raw kopia identity

No Snapshot CR to point at — a snapshot written by a foreign kopia client, or one that aged out of the catalog? Restore by the raw kopia identity (username@hostname:path). This mode requires an explicit spec.repository (there's nothing to infer it from). Pin an exact snapshotID, or select with asOf / offset.

# Example 13 — Restore by raw kopia identity
#
# When there is no `Snapshot` CR to point at — a snapshot written by a foreign kopia
# client, or one that aged out of the catalog — restore by the raw kopia identity
# (`username@hostname:path`). This mode REQUIRES an explicit `spec.repository`
# (there's no Backup/SnapshotPolicy to infer it from).
#
#   # find identities/snapshots in a repo with the kopia CLI (or `kubectl get snapshots`
#   # for catalogued ones):
#   kopia snapshot list --all
#
# Default identity is `<snapshotPolicyName>@<namespace>:/pvc/<pvcName>` — see
# docs/concepts/how-kopia-works.md.
#
# Field shapes verified against crates/api: source.identity is
# { username, hostname, sourcePath?, snapshotID?, asOf?, offset? }; `snapshotID`
# has a capital ID on the wire.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: postgres-restore-foreign
  namespace: billing
spec:
  # Identity restores can't infer the repo — name it explicitly.
  repository:
    kind: Repository
    name: nas-primary
    namespace: backups
  source:
    identity:
      username: postgres-data # kopia username to match
      hostname: billing # kopia hostname to match
      sourcePath: /data # optional; omit to match any path for this identity
      snapshotID: k1f1ec0a8 # pin an exact snapshot (or use asOf / offset below)
      # asOf: 2026-05-01T00:00:00Z   # newest snapshot at/before this instant
      # offset: 0                    # 0 = latest, 1 = previous, ...
  target:
    pvc:
      name: postgres-data-restored
      storageClassName: fast-ssd
      capacity: 100Gi
      accessModes:
        - ReadWriteOnce
  policy:
    onMissingSnapshot: Fail # explicit sources fail-closed
    waitTimeout: 5m

Example 14 — Point-in-time / offset restore

"Roll back to Tuesday 2am" without hunting for the exact Snapshot CR. source.fromPolicy resolves through the SnapshotPolicy's identity and takes asOf (newest snapshot at/before an instant) or offset (0 = latest, 1 = previous, …) — so it works even when the matching Snapshot CR has aged out. Restore into a side-by-side PVC and compare; see scenario 07.

# Example 14 — Point-in-time / offset restore
#
# Restore the snapshot a config had at (or before) a specific moment — "roll back to
# Tuesday 2am" — without hunting for the exact Snapshot CR. `source.fromPolicy`
# resolves through the SnapshotPolicy's identity, so it works even when the matching
# Snapshot CR has aged out, and accepts:
#   - asOf:   newest snapshot AT OR BEFORE an RFC3339 instant (point-in-time)
#   - offset: 0 = latest, 1 = previous, 2 = the one before that, ...
# (`offset` and `asOf` are alternatives — set one.)
#
# Restore into a NEW, side-by-side PVC and compare before cutting over (see
# scenario 07 — point-in-time rollback).
#
# Field shapes verified against crates/api: source.fromPolicy is
# { name, namespace?, asOf?, offset? }.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: postgres-restore-asof
  namespace: billing
spec:
  source:
    fromPolicy:
      name: postgres-data # the SnapshotPolicy whose identity selects the snapshot
      # newest snapshot at/before this instant — the point you want to roll back to:
      asOf: 2026-05-20T02:00:00Z
      # offset: 1          # ...or by position: the previous snapshot instead of asOf
  target:
    pvc:
      name: postgres-data-asof
      storageClassName: fast-ssd
      capacity: 100Gi
      accessModes:
        - ReadWriteOnce
  policy:
    # fromPolicy defaults to Continue (deploy-or-restore); for a deliberate
    # point-in-time recovery you usually want it to FAIL if nothing matches.
    onMissingSnapshot: Fail
    waitTimeout: 5m

Example 15 — In-place mirror restore

Restore straight into an existing PVC (target.pvcRef) and make it an exact mirror of the snapshot with options.enableFileDeletion: true (files not in the snapshot are deleted). The faithful "put it back exactly how it was" restore — use it deliberately, and scale the app down first.

enableFileDeletion is destructive

By default a restore is additive. enableFileDeletion: true deletes target files that aren't in the snapshot — point it at the wrong PVC and it wipes the extras. Scale the workload to zero so nothing writes the target mid-restore.

# Example 15 — In-place mirror restore into an existing PVC
#
# Restore directly into an EXISTING PVC (`target.pvcRef`) and make it an EXACT mirror
# of the snapshot with `options.enableFileDeletion: true` — files in the target that
# aren't in the snapshot are DELETED (kopia's `--delete-extra`). This is the faithful
# "put it back exactly how it was" restore.
#
# !! Use deliberately. By default a restore is ADDITIVE (writes the snapshot's files,
#    leaves everything else). enableFileDeletion makes it destructive — point it at
#    the wrong PVC and it wipes the extra files. Scale the app DOWN first so nothing
#    is writing the target during the restore.
#
# Also shows `options.parallel` (restore parallelism, kopia default 8) and
# `options.skipTimes` (skip restoring file modification times) — two of the M2
# flag-sweep tuning knobs, all of which mirror kopia's own `snapshot restore` flags.
#
# Field shapes verified against crates/api: target.pvcRef = { name }; options =
# { enableFileDeletion, ignorePermissionErrors?, writeFilesAtomically?, parallel?,
#   writeSparseFiles?, skipOwners?, skipPermissions?, skipTimes?, overwriteFiles?,
#   overwriteDirectories?, overwriteSymlinks?, ignoreErrors?, skipExisting? }.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: postgres-restore-in-place
  namespace: billing
spec:
  source:
    snapshotRef:
      name: postgres-data-20260523-021300
  target:
    # Write into an EXISTING PVC (the live one, ideally with the app scaled to 0).
    pvcRef:
      name: postgres-data
  options:
    enableFileDeletion: true # EXACT mirror — delete target files not in the snapshot (--delete-extra)
    ignorePermissionErrors: true
    writeFilesAtomically: true
    parallel: 4 # restore parallelism; kopia default 8
    skipTimes: true # don't restore file modification times
  # Match the UID/GID that owns the data so the rewritten files land correctly.
  mover:
    securityContext:
      runAsUser: 1000
      runAsGroup: 1000
      runAsNonRoot: true
      allowPrivilegeEscalation: false
      capabilities:
        drop: ["ALL"]
      seccompProfile:
        type: RuntimeDefault
  policy:
    onMissingSnapshot: Fail
    waitTimeout: 5m

Example 16 — Cross-namespace clone restore

Restore a snapshot taken in one namespace into another — e.g. clone production data into staging to reproduce a bug against real data. The snapshotRef carries the source namespace; the Restore and its target PVC live in the destination. The mover runs in the destination, so the repo credentials must be readable there (a shared ClusterRepository + credentialProjection handles that — example 17). See scenario 08.

# Example 16 — Cross-namespace clone restore
#
# Restore a snapshot taken in one namespace INTO another — e.g. clone production data
# into a `staging` namespace to reproduce a bug against real data. The `snapshotRef`
# carries the SOURCE namespace; the Restore (and its target PVC) live in the
# DESTINATION namespace.
#
# The mover runs in the DESTINATION namespace, so the repository's credential Secret
# must be readable there. With a shared `ClusterRepository`, set
# `credentialProjection.enabled: true` to have the operator copy it in for you
# (see example 17 / example 11).
#
# Field shapes verified against crates/api: source.snapshotRef = { name, namespace? }.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: clone-prod-into-staging
  namespace: staging # DESTINATION — where the clone lands
spec:
  repository:
    kind: Repository
    name: postgres-primary
    namespace: billing # the prod repository (cross-namespace read)
  source:
    snapshotRef:
      name: postgres-data-20260523-021300
      namespace: billing # SOURCE — the prod namespace the Snapshot lives in
  target:
    pvc:
      name: postgres-data-clone
      storageClassName: standard # staging can use cheaper storage
      capacity: 100Gi
      accessModes:
        - ReadWriteOnce
  # Run the restore mover as the staging app's UID so it can use the data.
  mover:
    securityContext:
      runAsUser: 1000
      runAsGroup: 1000
      runAsNonRoot: true
      allowPrivilegeEscalation: false
      capabilities:
        drop: ["ALL"]
      seccompProfile:
        type: RuntimeDefault
  policy:
    onMissingSnapshot: Fail
    waitTimeout: 5m

Example 17 — Restore from a shared repo (projection)

Restoring from a shared ClusterRepository into a namespace that has never run a backup hits a chicken-and-egg: the mover loads the repo creds from a Secret in its namespace, which isn't there yet. credentialProjection.enabled: true has the operator copy the repository's Secret into the mover's namespace for the run (owned by the Restore, GC'd with it). Needs the operator's Secret-projection RBAC (Helm features.credentialProjection.enabled).

# Example 17 — Restore from a shared ClusterRepository, into a fresh namespace
#
# Restoring from a shared `ClusterRepository` into a namespace that has never run a
# backup hits a chicken-and-egg: the restore mover loads the repo credentials via
# `envFrom` from a Secret in ITS namespace, which isn't there yet. Set
# `credentialProjection.enabled: true` and the operator copies the repository's
# Secret into the mover's namespace for the run (owned by this Restore, GC'd with it)
# — so you don't hand-place credentials in every recovery namespace.
#
# Requires the operator's Secret-projection RBAC: Helm `features.credentialProjection.enabled`
# (off by default). See docs/movers.md.
#
# Field shapes verified against crates/api: credentialProjection = { enabled }.
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
  name: app-restore-fresh-ns
  namespace: recovered # a brand-new namespace with no creds Secret of its own
spec:
  repository:
    kind: ClusterRepository # cluster-scoped, shared across namespaces
    name: platform-shared
  source:
    fromPolicy:
      name: app-data
      namespace: apps # the SnapshotPolicy's namespace (its identity selects the snapshot)
  target:
    pvc:
      name: app-data-restored
      capacity: 50Gi
      accessModes:
        - ReadWriteOnce
  # Copy the ClusterRepository's credential Secret into `recovered` for this run.
  credentialProjection:
    enabled: true
  policy:
    onMissingSnapshot: Fail
    waitTimeout: 5m

Example 18 — Inherit the mover security context from a workload

Instead of hard-coding the mover's UID/GID (example 09), spec.mover.inheritSecurityContextFrom copies the securityContext from a live workload pod onto the mover, so it runs as "whatever the app runs as." On a backup the simplest form is pvcConsumer: {} — Kopiur auto-derives the pod mounting the source PVC; or name the workload by label with workloadSelector (required on a Restore, whose target consumer may not exist yet). Mutually exclusive with securityContext; an inherited root context is still gated by the privileged-movers opt-in. See Security context → Inherit it from the workload.

# 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

Example 19 — Repository replication

Mirror a repository's blobs to a second backend on a schedule (kopia repository sync-to) — the off-site copy that makes a 3-2-1 strategy. A RepositoryReplication is namespaced, references its source via sourceRef, and writes to a destination backend that must differ from the source. The destination's own access credentials ride its auth.secretRef (co-resident in the CR's namespace); sync-to is a blob copy, so the mirror reuses the source repo's password. See Repository replication.

# Example 19 — Repository replication (the "2" in 3-2-1)
#
# A RepositoryReplication mirrors a source repository's blobs to a SECOND backend
# on a schedule (`kopia repository sync-to`). It is the off-site copy that turns a
# single repository into a 3-2-1 strategy: same data, a second medium/location.
#
# RepositoryReplication is NAMESPACED (it lives alongside its source repository,
# like Maintenance) and references a Repository or ClusterRepository via
# `sourceRef`. The destination is exactly one backend (the same externally-tagged
# `Backend` shape used by Repository) and MUST differ from the source's backend.
#
# Encryption is NOT a knob here: `sync-to` copies blobs verbatim, so the mirror
# always reuses the SOURCE repository's format and password. Only the destination
# backend's own ACCESS credentials (e.g. S3 keys) are configured, via its
# `auth.secretRef` below. That Secret must live in THIS CR's namespace (the mover
# loads it with envFrom, which is namespace-local) and use the same key names a
# source Secret would (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY). The source and
# destination may use entirely different credentials — they are kept separate.
#
# Field shapes verified against crates/api: sourceRef is a RepositoryRef
# (kind defaults to Repository); destination is an externally-tagged Backend;
# schedule is { cron, jitter }.
#
# The `# --8<-- [start:…]` / `# --8<-- [end:…]` lines are snippet-section markers
# (docs/replication.md pulls the RepositoryReplication CR on its own). Plain YAML
# comments — the file stays `kubectl apply -f`-able as-is.
---
apiVersion: v1
kind: Secret
metadata:
  name: offsite-mirror-creds
  namespace: billing
type: Opaque
stringData:
  # Credentials for the DESTINATION backend (here a second S3 bucket / region).
  AWS_ACCESS_KEY_ID: "REPLACE_ME"
  AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: RepositoryReplication
metadata:
  name: nas-primary-offsite
  namespace: billing
spec:
  # Mirror FROM this repository (must already exist — see example 01).
  sourceRef:
    kind: Repository
    name: nas-primary
  # Mirror TO this backend. Exactly one backend; must differ from the source.
  destination:
    s3:
      bucket: offsite-mirror
      prefix: prod/
      region: us-west-2
      auth:
        # The destination backend's OWN credentials. Must be a Secret in this CR's
        # namespace (envFrom is namespace-local); the mover delivers it under a
        # KOPIUR_DEST_ prefix so it never collides with the source's keys.
        secretRef:
          name: offsite-mirror-creds
  schedule:
    cron: "0 5 * * *" # nightly, after the 02:xx backups have landed
    jitter: 1h
  # Tuning for `kopia repository sync-to` (issue #216). Every field is optional;
  # omitting `sync` (or any field within it) reproduces kopia's own default.
  # `parallel` is the main knob: sync-to copies blobs one at a time by default,
  # so a large initial seed to a slow/high-latency destination can otherwise
  # take days. `deleteExtra` (kopia's `--delete`) prunes destination-only blobs
  # for a true mirror — left `false` (additive) here since it deletes
  # destination content; see docs/replication.md#tuning-the-sync.
  sync:
    parallel: 8
  # Pause replication without deleting the CR.
  suspend: false

Example 20 — Quiesce with hooks

App-consistent backups: spec.hooks runs commands in the workload (the controller execs into your pod — the mover never runs hooks) before and after the snapshot. Here PostgreSQL is put into backup mode (beforeSnapshot workloadExec), resumed afterwards, and a notifier is called (httpRequest with continueOnFailure: true so a flaky notifier can't fail the backup). The runJob form (a full one-shot Job, the k8up PreBackupPod analog) is shown in a comment. A failing hook aborts the backup unless that hook opts out; afterSnapshot hooks run whether the backup succeeded or failed, so a resume can't be skipped. See Backups → hooks.

# Example 20 — Quiesce with hooks
#
# App-consistent backups: hooks run IN THE WORKLOAD (the controller execs into
# your pod; the mover never runs hooks) around the snapshot. The classic pairing
# is quiesce/resume — here PostgreSQL's backup mode — plus an httpRequest
# notification. Three hook forms exist (exactly ONE per list entry, externally
# tagged): `workloadExec` (exec into a matched pod), `runJob` (materialize a full
# one-shot Job — the k8up PreBackupPod analog), `httpRequest` (call a URL).
#
# Semantics worth knowing before you rely on them:
#   - A failing hook ABORTS the backup unless that hook sets
#     `continueOnFailure: true`.
#   - `afterSnapshot` hooks run whether the backup succeeded OR failed — a
#     resume hook must not depend on backup success, or a failed backup would
#     leave your database quiesced.
#   - Each list runs exactly once per Snapshot (stamped on
#     `status.hooks.preCompletedAt` / `postCompletedAt`).
#
# Field shapes verified against crates/api (Hook is externally tagged:
# `- workloadExec: {...}`, NOT `- kind: WorkloadExec`).
---
apiVersion: v1
kind: Secret
metadata:
  name: nas-primary-creds
  namespace: databases
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: nas-primary
  namespace: databases
spec:
  backend:
    s3:
      bucket: my-backups
      prefix: databases/
      endpoint: s3.us-east-1.amazonaws.com
      region: us-east-1
      auth:
        secretRef:
          name: nas-primary-creds
  encryption:
    passwordSecretRef:
      name: nas-primary-creds
      key: KOPIA_PASSWORD
  create:
    enabled: true
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: postgres-data
  namespace: databases
spec:
  repository:
    kind: Repository
    name: nas-primary
  sources:
    - pvc:
        name: postgres-data # the PVC your postgres pod mounts
  retention:
    keepDaily: 7
    keepWeekly: 4
    keepMonthly: 6
  hooks:
    beforeSnapshot:
      # Quiesce: put PostgreSQL into backup mode so the filesystem snapshot is
      # consistent. Runs INSIDE the matched workload pod, like `kubectl exec`.
      - workloadExec:
          podSelector:
            matchLabels:
              app: postgres # must match a RUNNING pod in this namespace
          container: postgres # optional; defaults to the pod's first container
          command: ["psql", "-U", "postgres", "-c", "SELECT pg_backup_start('kopiur')"]
          timeout: 2m # hook fails (and aborts the backup) past this
    afterSnapshot:
      # Resume: ALWAYS runs once the mover Job is terminal — success or failure —
      # so a failed backup can't leave the database in backup mode.
      - workloadExec:
          podSelector:
            matchLabels:
              app: postgres
          container: postgres
          command: ["psql", "-U", "postgres", "-c", "SELECT pg_backup_stop()"]
          timeout: 2m
      # Notify: a failing notification should not fail the backup → opt out of
      # the abort-by-default with continueOnFailure.
      - httpRequest:
          url: http://notifier.tools.svc:8080/fire # http://user:pass@… becomes Basic auth
          method: POST # the default; shown for teaching
          body: '{"event": "postgres-data backed up"}'
          # Optional request headers ({name, value} objects). Kopiur sends NO
          # default Content-Type with a body, so set one here if the endpoint
          # needs it. Names are case-insensitive RFC 7230 tokens and values must
          # be single-line; a duplicate name — or an explicit Authorization that
          # collides with URL user:pass@ credentials — is rejected at admission.
          headers:
            - name: Content-Type
              value: application/json
          timeout: 30s
          continueOnFailure: true
      # The third form, runJob, materializes a full one-shot Job — use it when the
      # work needs its own image or volumes rather than an exec into the app. The
      # finished Job self-reaps an hour later; set ttlSecondsAfterFinished yourself
      # to keep it around longer for a post-mortem, or to drop it sooner. (Don't
      # rely on the ownerRef to clean it up: the owning Snapshot is retained for
      # the whole retention window, so an untimed Job would outlive its run by
      # months.)
      #
      # - runJob:
      #     timeout: 5m
      #     jobSpec:
      #       backoffLimit: 0
      #       ttlSecondsAfterFinished: 3600
      #       template:
      #         spec:
      #           restartPolicy: Never
      #           containers:
      #             - name: dump
      #               image: postgres:17
      #               command: ["sh", "-c", "pg_dumpall -h postgres > /backup/dump.sql"]
      #               volumeMounts: [{ name: backup, mountPath: /backup }]
      #           volumes:
      #             - name: backup
      #               persistentVolumeClaim: { claimName: postgres-data }
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
  name: postgres-data-nightly
  namespace: databases
spec:
  policyRef:
    name: postgres-data
  schedule:
    cron: "0 2 * * *" # nightly at 02:00
    jitter: 30m