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.
Pick your track
Every manifest step below has two tabs. S3 covers AWS, MinIO, RustFS, Ceph RGW, and the rest. Filesystem (NAS) covers an NFS export on your NAS.
Pick one in any tab and the whole page follows. The selection is linked and it persists.
The two tracks are deliberately near-identical: only the Repository backend and one restore detail change.
Using Azure, GCS, B2, SFTP, WebDAV, or rclone instead? Follow the S3 track and swap in the Repository from your backend's page.
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¶
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-webhook
Every 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¶
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. The kopiur repository doubles as its own krew index:
$ kubectl krew index add kopiur https://github.com/home-operations/kopiur.git
$ kubectl krew install kopiur/kopiur
Smoke-test it. On a fresh install this prints an empty but healthy overview:
No krew? Each GitHub release attaches per-platform binaries. See the plugin page.
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: v1
kind: Secret
metadata:
name: walkthrough-creds
namespace: demo
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. 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: v1
kind: Secret
metadata:
name: walkthrough-creds
namespace: demo
type: Opaque
stringData:
# 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.
Lose the password, lose the backups
KOPIA_PASSWORD encrypts the repository. If you lose it, the backups are unrecoverable, because kopia cannot decrypt without it.
Generate something long and random, and store it in your password manager or secret store, not only in the cluster. A backup password that exists only in the cluster it's backing up defeats the purpose.
Step 3 — The Repository (where)¶
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: primary
namespace: demo
spec:
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/v1alpha1
kind: Repository
metadata:
name: primary
namespace: demo
spec:
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:
Stuck? 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)¶
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: demo
spec:
# 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: Delete
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: demo
spec:
# 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: Delete
This 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)¶
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
name: app-data-nightly
namespace: demo
spec:
# 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: Forbid
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
name: app-data-nightly
namespace: demo
spec:
# 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: Forbid
This 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, skips a firing if the previous one is somehow still running, rather than stacking movers on the same PVC.
$ kubectl -n demo get snapshotschedule app-data-nightly \
-o jsonpath='{.status.nextSchedule.at}'
2026-06-13T02:17:00Z
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:
This 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 demo
NAME POLICY ORIGIN PHASE SNAPSHOT-ID SIZE FILES START AGE
app-data-manual-20260612140012 app-data manual Succeeded a1b2c3d4e5f6 148 MiB 412 2026-06-12T14:00:12Z 1m
That 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¶
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/get
These 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)¶
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 --wait
Or do the same thing declaratively, as a manifest. Paste the Snapshot name into snapshotRef:
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
name: walkthrough-verify
namespace: demo
spec:
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:
- ReadWriteOnce
Filesystem 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/v1alpha1
kind: Restore
metadata:
name: walkthrough-verify
namespace: demo
spec:
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:
- ReadWriteOnce
This 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.
fromPolicy resolves "latest" on every backend
Resolving "latest for a policy" lists the repository's snapshots inside the restore Job. So it works on S3 and the other object-store backends just as it does on a filesystem repository, with no controller-side repository mount needed.
You can still name the snapshot explicitly with snapshotRef or --from-snapshot, or pin an exact ID through the identity source, when you don't want "latest".
$ kubectl -n demo wait --for=jsonpath='{.status.phase}'=Completed restore/walkthrough-verify --timeout=5m
Completed 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¶
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¶
$ 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-system
Deleting a Snapshot deletes its snapshot
With deletionPolicy: Delete, which is the default for produced snapshots and what Step 4 chose, removing a Snapshot CR runs kopia snapshot delete through a finalizer.
That is the lock-step behavior you opted into. Use Retain or Orphan per snapshot if a CR must go but the data must stay. See deletionPolicy.
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.