Complete walkthrough
Getting started is the 15-minute first run. This page is the long version. It follows the same journey, from install to repository to policy to schedule to snapshot to restore, but it explains the reasoning behind every choice and value. It also adds a second track for NAS users, and it uses the kubectl kopiur plugin as the day-2 interface throughout.
Read this page when you’re past “does it work” and into “what should my setup look like, and why”.
Here is the mental model in one paragraph; the Concepts page has the full story. A Repository is where snapshots live. A SnapshotPolicy is the recipe for what to back up, and it runs nothing on its own. A Snapshot is one invocation of a recipe, as a Kubernetes object. A SnapshotSchedule is the cron that creates those invocations. A Restore reads a snapshot back into a PVC. Everything below is those five pieces, in order.
What you’ll build
Section titled “What you’ll build”| Stage | Resource | Day-2 CLI verb |
|---|---|---|
| Where snapshots live | Repository + a Secret |
kubectl kopiur status / doctor |
| What to back up | SnapshotPolicy |
kubectl kopiur snapshot now |
| When it runs | SnapshotSchedule |
kubectl kopiur suspend / resume |
| One backup | Snapshot (created for you) |
kubectl kopiur snapshots list / logs |
| Proof it works | Restore |
kubectl kopiur restore / ls / browse |
Each track ships as one apply-ready bundle: deploy/examples/walkthrough/s3.yaml and deploy/examples/walkthrough/nas.yaml. Every YAML block below is pulled from them at build time, stage by stage.
You can follow along one step at a time, or fill in the REPLACE_ME values and apply a whole bundle at once. The operator works out the ordering.
You need a cluster at version 1.24 or later, Helm, kubectl, and a PVC with data in it. The walkthrough assumes a PVC named app-data in namespace demo. The Getting started prerequisites show how to create a throwaway one.
Step 0 — Choices before you install
Section titled “Step 0 — Choices before you install”The Helm install itself is two commands. Here are the decisions worth making deliberately first.
Install scope. The default is installScope=cluster. It lets one operator watch every namespace, because its RBAC is a ClusterRole, and it is required for ClusterRepository. That is the shared-repository pattern, where a platform team owns the storage and tenant namespaces reference it without ever seeing credentials. Cluster scope is the default because a namespace-scoped Role silently disables that cluster-scoped kind.
Choose installScope=namespaced when you deliberately want less privilege, confining the operator to objects in its own release namespace with a Role instead of a ClusterRole. ClusterRepository is then not reconciled. Moving between scopes later is a Helm upgrade, not a migration. For details see Installation → Install scope.
Webhook TLS. Kopiur validates and defaults your resources through an admission webhook, and that webhook needs a serving certificate.
The default is webhook.tls.mode=self, which means the operator mints and rotates the certificate itself. It has no dependencies and is the right answer unless you already run cert-manager, in which case cert-manager keeps all your certificates in one system. Use manual for clusters where certificates must come from your own PKI. For details see Installation → Webhook TLS.
CRD lifecycle. The CRDs ship in the chart’s special crds/ directory. helm install installs them, but helm upgrade never touches them.
So on a helm-CLI upgrade that carries a schema change, you apply them yourself with kubectl apply --server-side -f deploy/crds/. A GitOps CRD pipeline with a CreateReplace sync handles it automatically. For details see Installation → CRD lifecycle.
With the defaults chosen deliberately, install:
$ helm install kopiur oci://ghcr.io/home-operations/charts/kopiur --namespace kopiur-system --create-namespace$ kubectl -n kopiur-system rollout status deploy/kopiur-controller$ kubectl -n kopiur-system rollout status deploy/kopiur-webhookEvery other knob lives in Helm chart values: images by digest, resources, replicas for high availability, observability. None of them block a first run.
Step 1 — Install the kubectl plugin
Section titled “Step 1 — Install the kubectl plugin”Everything in this walkthrough can be done with raw YAML and kubectl get -w.
The plugin exists because day-2 operations are imperative by nature. “Back up now.” “What failed?” “Show me the files in that snapshot.” Those deserve commands with --wait, streamed logs, and meaningful exit codes, rather than hand-rolled watch loops.
Install it through krew, from the official krew-index:
$ kubectl krew install kopiurSmoke-test it. On a fresh install this prints an empty but healthy overview:
$ kubectl kopiur status -n demoNo krew? Each GitHub release attaches per-platform binaries. See the plugin page.
Step 2 — Credentials
Section titled “Step 2 — Credentials”The mover Job that actually runs kopia reads its secrets from a Secret in the same namespace as the data, which is demo here. The Secret is loaded with envFrom, which is namespace-local, so credentials never pass through the operator.
What goes in that Secret differs by track, and this is the first place the two tracks teach different lessons:
apiVersion: v1kind: Secretmetadata: name: walkthrough-creds namespace: demotype: OpaquestringData: # 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. Generate it (e.g. `openssl rand -base64 24`) # and store it OUTSIDE the cluster too — lose it and the backups are # unrecoverable; kopia cannot decrypt without it. KOPIA_PASSWORD: "choose-something-long-and-random"apiVersion: v1kind: Secretmetadata: name: walkthrough-creds namespace: demotype: OpaquestringData: # Filesystem/NFS backends need ONLY the repository encryption password — no # object-store keys. The data sits on your NAS, but kopia still encrypts it, # so a stolen disk or a curious houseguest reads ciphertext. # Generate the password (e.g. `openssl rand -base64 24`) and store it OUTSIDE # the cluster too — lose it and the backups are unrecoverable. KOPIA_PASSWORD: "choose-something-long-and-random"The S3 track carries the object-store keys, meaning the AWS_* values, which are read by well-known names. The per-backend key table covers the other backends.
The NAS track needs only KOPIA_PASSWORD. There is no storage account to authenticate to, but kopia still encrypts everything it writes, so your NAS holds ciphertext either way.
Step 3 — The Repository (where)
Section titled “Step 3 — The Repository (where)”apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: primary namespace: demospec: backend: s3: bucket: my-backups # One bucket can host many repositories — give each its own prefix. A repo # is also an encryption + blast-radius boundary: a leaked password or a bad # actor in `demo/` can't touch `prod/`. prefix: demo/ # Omit for AWS (derived from region); REQUIRED for MinIO / RustFS / Ceph # RGW and any other S3-compatible endpoint. endpoint: s3.us-east-1.amazonaws.com region: us-east-1 auth: secretRef: name: walkthrough-creds # the AWS_* keys above encryption: passwordSecretRef: name: walkthrough-creds key: KOPIA_PASSWORD # which key in the Secret holds the password # Initialize a brand-new kopia repository if the prefix is empty. Set false # (or omit the block) when adopting a repository that already exists — then a # missing repo is an error, not something to paper over. create: enabled: true # Maintenance is default-managed: omit this block entirely and the operator # creates and owns a Maintenance with exactly these values (quick every 6h, # full daily at 03:00). It is spelled out here so you can see the knobs; # `enabled: false` opts out. maintenance: enabled: true schedule: quick: { cron: "0 */6 * * *", jitter: 30m } full: { cron: "0 3 * * *", jitter: 1h }apiVersion: kopiur.home-operations.com/v1alpha1kind: Repositorymetadata: name: primary namespace: demospec: backend: filesystem: path: /repo # mount path INSIDE the mover pod where kopia writes the repo volume: nfs: server: nas.lan # NFS server hostname or IP # The export on the server (absolute path). One subdirectory per repo # plays the same isolation role as an S3 prefix. path: /export/kopia/demo encryption: passwordSecretRef: name: walkthrough-creds key: KOPIA_PASSWORD # which key in the Secret holds the password # Initialize a brand-new kopia repository if the directory is empty. Set false # (or omit the block) when adopting a repository that already exists — then a # missing repo is an error, not something to paper over. # # PERMISSIONS: the export must be WRITABLE by the UID the mover runs as # (default 65532). If create/connect fails with "permission denied", the # operator's Warning Event names the exact UID and the `chown -R <uid> <path>` # to run on the NAS. See the Permissions guide. create: enabled: true # Maintenance is default-managed: omit this block entirely and the operator # creates and owns a Maintenance with exactly these values (quick every 6h, # full daily at 03:00). It is spelled out here so you can see the knobs; # `enabled: false` opts out. maintenance: enabled: true schedule: quick: { cron: "0 */6 * * *", jitter: 30m } full: { cron: "0 3 * * *", jitter: 1h }The values worth pausing on:
- One bucket, many repositories. On S3 you separate them with
prefix; on a NAS, with a subdirectory per repository. A repository is an encryption boundary and a blast-radius boundary, so a leaked password fordemo/reads nothing fromprod/. The trade-off is that kopia deduplicates within a repository, so two namespaces backing up similar data into separate repositories store it twice. For guidance on drawing that line, see Repositories & backends. create.enabled: trueinitializes a brand-new kopia repository when the target is empty. When you’re adopting an existing repository, such as one written by another cluster or by VolSync, set it tofalseso a typo in the bucket name fails loudly instead of quietly initializing an empty repository. See the migration guide.maintenanceis managed by default. Omit the block entirely and the operator creates an ownedMaintenancewith exactly the values shown: quick every 6 hours, full daily. It’s spelled out in the bundle so you can see the knobs, but most users should delete the block and take the default.- NAS only: permissions. The export must be writable by the mover’s UID, which defaults to 65532. If it isn’t, the operator’s Warning Event names the exact
chownto run. See Permissions, UID & GID.
Apply, then wait for Ready. Everything else waits on this, and every Kopiur CRD exposes a standard Ready condition for exactly that purpose:
$ kubectl -n demo wait --for=condition=Ready repository/primary --timeout=2mStuck? Run kubectl -n demo describe repository primary. Its conditions and events name the actual cause: wrong keys, an unreachable endpoint or NAS, or a missing repository with create.enabled: false. You can also jump ahead to kubectl kopiur doctor.
Step 4 — The SnapshotPolicy (what, and for how long)
Section titled “Step 4 — The SnapshotPolicy (what, and for how long)”apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: demospec: # repository.kind defaults to Repository; shown explicitly for clarity. repository: kind: Repository name: primary sources: - pvc: name: app-data # the PVC to back up # Direct (opt-in; pinned explicitly here since this walkthrough targets # arbitrary storage with no assumed CSI snapshot support) reads the live # volume — zero CSI requirements, fine for files that don't rewrite in # place. `Snapshot` (CSI VolumeSnapshot) is the CRD default and is the # right choice for databases and RWOP volumes once your CSI driver supports # it; `Clone` where your driver clones faster than it snapshots. See the # copy-methods guide. copyMethod: Direct # GFS retention is the ONLY thing that prunes successful backups. Each tier # answers a different recovery question: retention: keepDaily: 14 # "restore any day from the last two weeks" keepWeekly: 8 # "...any week from the last two months" keepMonthly: 6 # "...any month from the last half year" # What deleting a produced Snapshot CR does to the kopia snapshot behind it. # Delete (the default, explicit here) keeps cluster state and repository in # lock-step. Retain/Orphan keep the data when the CR goes — see the backups # guide before changing this. defaultDeletionPolicy: DeleteapiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotPolicymetadata: name: app-data namespace: demospec: # repository.kind defaults to Repository; shown explicitly for clarity. repository: kind: Repository name: primary sources: - pvc: name: app-data # the PVC to back up # Direct (opt-in; pinned explicitly here since this walkthrough targets # arbitrary storage with no assumed CSI snapshot support) reads the live # volume — zero CSI requirements, fine for files that don't rewrite in # place. `Snapshot` (CSI VolumeSnapshot) is the CRD default and is the # right choice for databases and RWOP volumes once your CSI driver supports # it; `Clone` where your driver clones faster than it snapshots. See the # copy-methods guide. copyMethod: Direct # GFS retention is the ONLY thing that prunes successful backups. Each tier # answers a different recovery question: retention: keepDaily: 14 # "restore any day from the last two weeks" keepWeekly: 8 # "...any week from the last two months" keepMonthly: 6 # "...any month from the last half year" # What deleting a produced Snapshot CR does to the kopia snapshot behind it. # Delete (the default, explicit here) keeps cluster state and repository in # lock-step. Retain/Orphan keep the data when the CR goes — see the backups # guide before changing this. defaultDeletionPolicy: DeleteThis is identical in both tracks, because the policy doesn’t care where the bytes land. The reasoning behind each value:
- Retention is GFS, which stands for grandfather-father-son, and it is the only thing that prunes successful backups. Don’t pick numbers; answer recovery questions instead.
keepDaily: 14means “I can restore any day from the last two weeks”, which covers noticing corruption a week later.keepWeekly: 8means “any week from the last two months”, which covers slow-burn mistakes.keepMonthly: 6means “any month from the last half year”, which covers compliance and archaeology. Thanks to deduplication, the extra cost of the older tiers is small, because they share unchanged data with newer snapshots. copyMethod: Directreads the live volume. It has no CSI requirements, and it is a reasonable choice for data that doesn’t rewrite in place, when you’d rather not depend on the CSI snapshot stack. It is pinned explicitly here because it is no longer the CRD default. The moment a database is involved, reach forSnapshot, which is the default. That takes a CSI VolumeSnapshot first, so kopia reads a crash-consistent point in time, and it is also the only way to back upReadWriteOncePodvolumes.Clonesuits drivers that clone faster than they snapshot. For the decision table, see Copy methods. For application-consistent backups, which quiesce the app first, see hooks.defaultDeletionPolicy: Deleteties each producedSnapshotCR to its kopia snapshot. Delete the CR and the data goes too, so cluster state stays truthful.RetainandOrphandecouple them, which is useful. Read the deletionPolicy section before choosing, because “I deleted the CR but the repository kept growing” and “I deleted the CR and lost the snapshot” are both surprises you get with the wrong setting.
This policy backs up one PVC. Label-selector sources, meaning every PVC matching app=web and grouped consistently, and NFS sources are the same resource with a different sources entry. Backups & schedules covers them.
Step 5 — The SnapshotSchedule (when)
Section titled “Step 5 — The SnapshotSchedule (when)”apiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotSchedulemetadata: name: app-data-nightly namespace: demospec: # Which recipe (SnapshotPolicy) to invoke each firing. policyRef: name: app-data schedule: # `H` = a deterministic per-schedule minute (hashed from the schedule's UID), # so fifty schedules saying "nightly at 2" don't all fire at 02:00 sharp. cron: "H 2 * * *" # On top of H, spread each firing over a window — kinder to the repository # and the storage backend when many policies share them. jitter: 30m # GitOps-friendly: applying this manifest does NOT take an immediate backup. # Set true if "create schedule" should also mean "back up right now". runOnCreate: false # If last night's backup is somehow still running, skip this firing rather # than stack a second one (the default, explicit here). `Replace` cancels # the old run; `Allow` runs both. concurrencyPolicy: ForbidapiVersion: kopiur.home-operations.com/v1alpha1kind: SnapshotSchedulemetadata: name: app-data-nightly namespace: demospec: # Which recipe (SnapshotPolicy) to invoke each firing. policyRef: name: app-data schedule: # `H` = a deterministic per-schedule minute (hashed from the schedule's UID), # so fifty schedules saying "nightly at 2" don't all fire at 02:00 sharp. cron: "H 2 * * *" # On top of H, spread each firing over a window — kinder to the repository # and the storage backend when many policies share them. jitter: 30m # GitOps-friendly: applying this manifest does NOT take an immediate backup. # Set true if "create schedule" should also mean "back up right now". runOnCreate: false # If last night's backup is somehow still running, skip this firing rather # than stack a second one (the default, explicit here). `Replace` cancels # the old run; `Allow` runs both. concurrencyPolicy: ForbidThis is also identical in both tracks. Why these values:
cron: "H 2 * * *".His a Jenkins-style placeholder. It resolves to a fixed minute derived from the schedule’s identity, so this fires at, say, 02:17 every night. Fifty teams writing “nightly at 2” then stop stampeding the repository at 02:00:00, with nobody coordinating. The resolved next firing is pinned tostatus.nextSchedule.at.jitter: 30mspreads the start across a window on top ofH. It is kinder to the backend when many policies share it, and it costs you nothing for a nightly backup.runOnCreate: false, the default, means applying this manifest does not immediately fire a backup. That is what you want under GitOps, where a re-applied manifest shouldn’t mean a surprise snapshot at 3 pm. The trade-off is that your first backup waits for tonight, which is why the next step triggers one by hand.concurrencyPolicy: Forbid, the default, holds a firing while an earlier run of the same policy is still going, rather than stacking movers on the same PVC. That includes a run you started by hand withkubectl kopiur snapshot now: the scheduled slot waits for it, then fires. The repository’sspec.concurrency.maxConcurrentJobsis the hard cap on how many runs, manual and scheduled, hit the repository at once.
$ kubectl -n demo get snapshotschedule app-data-nightly \ -o jsonpath='{.status.nextSchedule.at}'2026-06-13T02:17:00ZStep 6 — First snapshot, the day-2 way
Section titled “Step 6 — First snapshot, the day-2 way”The schedule will produce Snapshot CRs nightly. Don’t wait for it. Trigger the recipe now, watch it run, and stream the mover’s logs, all in one command:
$ kubectl kopiur snapshot now --policy app-data -n demo --wait --logsThis creates a Snapshot CR with origin: manual. It is exactly the object the schedule creates nightly with origin: scheduled, so what you just verified is what runs unattended from now on.
Two useful flags: --tag reason=walkthrough attaches searchable kopia tags, and --pin exempts a snapshot from GFS retention, which is handy before a risky migration.
--wait exits 0 on Succeeded and 1 on Failed, so the same command drops straight into CI and scripts.
Then look at what exists:
$ kubectl kopiur snapshots list -n demoNAME POLICY ORIGIN PHASE SNAPSHOT-ID SIZE FILES START AGEapp-data-manual-20260612140012 app-data manual Succeeded a1b2c3d4e5f6 148 MiB 412 2026-06-12T14:00:12Z 1mThat view is richer than kubectl get snapshots, but the CRs are still ordinary objects, so both views work.
For a failed run, kubectl kopiur logs snapshot <name> -n demo replays the mover’s logs even after the Job is gone. See the logs command.
Step 7 — Look inside the repository
Section titled “Step 7 — Look inside the repository”Before you trust a restore during a 2 a.m. incident, look at what’s actually in a snapshot. This is read-only, and it restores nothing:
$ kubectl kopiur ls app-data-manual-20260612140012 -n demo$ kubectl kopiur cat app-data-manual-20260612140012 etc/config.yaml -n demo$ kubectl kopiur browse app-data-manual-20260612140012 -n demo # interactive: ls/cd/cat/getThese run through a short-lived in-cluster session pod. It mounts nothing from your workloads and can only read the repository. The session-pod model explains the security boundary.
Browsing has its own RBAC switch, rbac.browse in the chart, which defaults to true. Platform teams can turn it off for everyone.
Step 8 — Restore (prove the round trip)
Section titled “Step 8 — Restore (prove the round trip)”A backup you’ve never restored is a hope, not a backup. Restore into a fresh PVC and compare, so the original is never touched.
Here the two tracks differ for the second and last time:
Object-store backends name an explicit snapshot. As a one-liner, pick the snapshot from snapshots list, restore it, and follow it until it’s done:
$ kubectl kopiur restore --from-snapshot app-data-manual-20260612140012 \ --create-pvc app-data-restored --size 1Gi -n demo --waitOr do the same thing declaratively, as a manifest. Paste the Snapshot name into snapshotRef:
apiVersion: kopiur.home-operations.com/v1alpha1kind: Restoremetadata: name: walkthrough-verify namespace: demospec: source: # Object-store backends name an explicit snapshot: paste the Snapshot CR # name from `kubectl kopiur snapshots list`. (Latest-for-a-policy resolution # — `fromPolicy` — needs a locally mountable repo and is filesystem-only; # see the NAS track.) snapshotRef: name: REPLACE_ME target: # A fresh PVC, so the verification never touches the original volume. The # operator creates it; capacity is REQUIRED — it won't guess a size. pvc: name: app-data-restored capacity: 1Gi accessModes: - ReadWriteOnceFilesystem repositories can resolve “the latest snapshot for this policy” at restore time, so there is no snapshot name to paste:
apiVersion: kopiur.home-operations.com/v1alpha1kind: Restoremetadata: name: walkthrough-verify namespace: demospec: source: # The filesystem-backend superpower: resolve "the latest snapshot for this # policy's identity" at restore time — no Snapshot CR name to paste. This # is what powers deploy-or-restore on a fresh cluster (example 05). fromPolicy: name: app-data offset: 0 # 0 = latest, 1 = previous, ... target: # A fresh PVC, so the verification never touches the original volume. The # operator creates it; capacity is REQUIRED — it won't guess a size. pvc: name: app-data-restored capacity: 1Gi accessModes: - ReadWriteOnceThis fromPolicy source is what powers deploy-or-restore. The same manifests restore data on a fresh cluster and back it up everywhere else. See example 05 and the GitOps guide.
The CLI equivalent is kubectl kopiur restore --from-policy app-data --create-pvc app-data-restored --size 1Gi -n demo --wait.
$ kubectl -n demo wait --for=jsonpath='{.status.phase}'=Completed restore/walkthrough-verify --timeout=5mCompleted means the data is in app-data-restored. Mount it in a pod and diff it against the original; that is the real proof.
The restored PVC is deliberately not owned by the Restore, so deleting the CR afterwards keeps the data.
If your app runs with an fsGroup and the restored files come out unreadable, see restore-side permissions.
Step 9 — Day-2 operations
Section titled “Step 9 — Day-2 operations”Here are the commands you’ll actually use after today, one line each. CLI → Operations has the detail.
kubectl kopiur status -n demogives you one screen: repositories, policies, schedules, in-flight work, and last and next runs. This is the morning-coffee view.kubectl kopiur doctor -n demois what you run when something is red. It checks the CRDs, the operator, the webhook, repository connectivity, credentials, and stuck work, and it exits 1 if any check fails, so it works in CI.kubectl kopiur suspend schedule app-data-nightly -n demoandresumepause and unpause firings around upgrades or maintenance windows. They setspec.suspend, so GitOps sees the change.kubectl kopiur maintenance run --repository primary -n demo --waitruns compaction and pruning out of band. You normally never need it, because maintenance is managed by default, as Step 3 explained. It exists for “I just deleted a terabyte and want the space back now”.
Teardown
Section titled “Teardown”$ kubectl -n demo delete snapshotschedule app-data-nightly$ kubectl -n demo delete restore walkthrough-verify # the restored PVC stays$ kubectl -n demo delete snapshot --all$ kubectl -n demo delete snapshotpolicy app-data$ kubectl -n demo delete repository primary$ helm uninstall kopiur -n kopiur-systemWhere to go next
Section titled “Where to go next”- Scenarios: the same machinery aimed at specific problems. Protecting a database with hooks, recovering deleted data, disaster recovery, cross-cluster migration, and restore drills.
- Repositories & backends:
ClusterRepositoryfor one shared repository across namespaces, and the other six backends. - Repository replication: mirror the repository to a second backend. This is the “2” in 3-2-1.
- Examples: the per-capability manifest ladder, when you need one specific pattern.
- Troubleshooting: when a step above doesn’t go green.