Getting started¶
This is the end-to-end walkthrough: from a cluster with nothing installed to a verified backup and a verified restore. It hand-holds every step and shows you exactly what to look for so you know each one worked. Budget ~15 minutes.
If you only want the install reference (every Helm value, scopes, cert options), see Installation. This page is the guided first run. Want the long version — the same journey with the reasoning behind every value, a NAS track, and the kubectl kopiur plugin woven in? That's the Complete walkthrough.
The mental model — read this first
Kopiur splits one job into three resources so each can change independently:
- a
Repositoryis where snapshots are stored (your S3 bucket, NAS, B2…); - a
SnapshotPolicyis the recipe — what to back up. It is idempotent and runs nothing on its own; - a
Snapshotis an invocation — one snapshot, as a Kubernetes object. It is the universal trigger (created by a schedule, bykubectl, or by automation); - a
SnapshotScheduleis the cron — when the recipe runs. It createsSnapshotCRs for you.
A Restore reads a snapshot back into a PVC. That's the whole model. Everything below is just those pieces in order.
For the full picture — how Kopia dedups, the username@hostname:path identity model, and why these are separate resources — see Concepts.
What you need¶
- A Kubernetes cluster (≥ 1.24) and
kubectlpointed at it. - Helm 3 or 4.
- A storage backend kopia can reach. This guide uses S3 / S3-compatible (AWS S3, MinIO, RustFS, Ceph RGW…). Any of the eight backends works the same way — only the
Repositorychanges. - A PersistentVolumeClaim with some data in it to back up. The walkthrough assumes one named
app-datain a namespace calleddemo.
One bundle, applied once
Every manifest on this page is a section of a single apply-ready file, deploy/examples/getting-started.yaml. Each step below shows its section so you understand what it does — but you don't apply them one at a time. Fill in the REPLACE_ME values (your backend keys and a generated KOPIA_PASSWORD), then apply the whole thing once:
Re-applying is idempotent, and the operator resolves the ordering for you — the Snapshot simply stays Pending until the Repository is Ready, then proceeds. So apply once now, then walk each step below to watch the pieces reconcile in turn. (The one exception is the manual Snapshot in Step 5, which uses generateName and so needs kubectl create, not apply — that step calls it out.)
No spare PVC?
These two sections — at the top of the same bundle — create the demo namespace and a throwaway PVC to follow along:
apiVersion: v1
kind: Namespace
metadata:
name: demo
# A throwaway PVC to follow along with. Skip this stage if you already have a
# PVC with data in it — just point the SnapshotPolicy below at that name instead.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
namespace: demo
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
You don't need a separate apply for them: they're applied along with everything else when you kubectl apply -f deploy/examples/getting-started.yaml in the credentials step below. Mount the PVC in a throwaway pod and write a file if you want to see real data move.
Step 1 — Install the operator¶
Install the chart into its own namespace. By default the operator manages the webhook's serving certificate itself — no cert-manager required. (Prefer cert-manager or a hand-supplied cert? See Installation → Webhook TLS.)
$ helm install kopiur oci://ghcr.io/home-operations/charts/kopiur \
--namespace kopiur-system --create-namespace
Verify the operator is up and the 8 CRDs are registered:
$ kubectl -n kopiur-system rollout status deploy/kopiur-controller
$ kubectl -n kopiur-system rollout status deploy/kopiur-webhook
$ kubectl get crd | grep kopiur.home-operations.com
snapshotpolicies.kopiur.home-operations.com ...
snapshots.kopiur.home-operations.com ...
snapshotschedules.kopiur.home-operations.com ...
clusterrepositories.kopiur.home-operations.com ...
maintenances.kopiur.home-operations.com ...
repositories.kopiur.home-operations.com ...
repositoryreplications.kopiur.home-operations.com ...
restores.kopiur.home-operations.com ...
Eight CRDs and two ready Deployments means the operator is live.
Step 2 — Give it credentials¶
The mover Job that runs kopia reads two things from a Secret: your backend access keys and the repository encryption password. That Secret must live in the same namespace as the data you back up (demo here) — the mover loads it with envFrom, which is namespace-local. See Movers, RBAC & credentials for the full why.
Fill in your backend keys and a generated KOPIA_PASSWORD in this section of the bundle:
apiVersion: v1
kind: Secret
metadata:
name: repo-creds
# Must live in the SAME namespace as the data you back up — the mover Job
# loads it with `envFrom`, which is namespace-local.
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: "REPLACE_ME"
This is the moment to apply the bundle — once the REPLACE_ME values are filled in, kubectl apply -f deploy/examples/getting-started.yaml creates the namespace, PVC, Secret, and every CR in one shot. The rest of the steps just watch each piece come up.
Prefer to create it imperatively?
Save the KOPIA_PASSWORD
The KOPIA_PASSWORD encrypts the repository. If you lose it, the backups are unrecoverable — kopia cannot decrypt without it. Store it in your password manager / secret store, not just in the cluster. The backend keys (AWS_*) are your object-store credentials; the well-known key names per backend are in the Repositories reference.
Step 3 — Create the Repository¶
Tell Kopiur where to store snapshots. create.enabled: true lets the operator initialize a brand-new kopia repository in the bucket; drop it (or set false) to require that one already exists.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
name: primary
namespace: demo
spec:
backend:
s3:
bucket: my-kopia-bucket
# Optional: share one bucket across repos by giving each its own prefix.
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: repo-creds # the AWS_* keys from the secret stage
encryption:
passwordSecretRef:
name: repo-creds
key: KOPIA_PASSWORD # which key in the Secret holds the password
# Initialize a brand-new kopia repository if the prefix is empty. Drop this
# block (or set false) to require that one already exists.
create:
enabled: true
Once the bundle is applied, wait for Ready — this is the gate everything else waits on:
$ kubectl -n demo get repository primary -w
NAME PHASE BACKEND AGE
primary Initializing S3 5s
primary Ready S3 12s
If it sticks in Pending/Failed, read the reason — Kopiur tells you exactly what's wrong:
(Common causes: wrong keys, unreachable endpoint, or a bucket that doesn't exist with create.enabled: false. See Troubleshooting.)
Step 4 — Write the recipe (SnapshotPolicy)¶
Now describe what to back up and how long to keep it. Retention is GFS (grandfather-father-son) and is the only thing that prunes successful backups.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
name: app-data
namespace: demo
spec:
repository:
name: primary # kind defaults to Repository (same namespace)
sources:
- pvc:
name: app-data # the PVC to snapshot
# GFS retention is the ONLY thing that prunes successful backups.
retention:
keepDaily: 7 # "restore any day from the last week"
keepWeekly: 4 # "...any week from the last month"
It was applied with the bundle; confirm it's registered:
A SnapshotPolicy runs nothing yet — it's the recipe. Next we invoke it.
Step 5 — Take your first backup (and watch it work)¶
Trigger one snapshot by creating a Snapshot that references the recipe:
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Snapshot
metadata:
generateName: app-data-manual- # let the API server pick a unique name
namespace: demo
spec:
policyRef:
name: app-data # the recipe to invoke once, right now
This is the one resource you create rather than apply: it uses generateName (so every invocation gets a fresh, unique name like app-data-manual-abc12), and kubectl apply can't track a server-named object. Run create against the bundle — the namespace, Secret, and CRs already exist (so kubectl reports them unchanged), and the Snapshot is the one new object it mints:
$ kubectl create -f deploy/examples/getting-started.yaml
snapshot.kopiur.home-operations.com/app-data-manual-abc12 created
$ kubectl -n demo get snapshots -w
NAME PHASE ORIGIN SNAPSHOT AGE
app-data-manual-abc12 Pending manual 2s
app-data-manual-abc12 Running manual 6s
app-data-manual-abc12 Succeeded manual k8f3c1a90 41s
Succeeded with a SNAPSHOT ID means the data is in your repository. Inspect the details:
$ kubectl -n demo get snapshot app-data-manual-abc12 -o jsonpath='{.status.stats}'
{"sizeBytes":...,"bytesNew":...,"filesNew":...}
If it stays Pending with no Job, the mover is blocked on a precondition (usually credentials) — the cause is on the Snapshot's conditions and as an Event. See Movers → Troubleshooting.
Step 6 — Restore it (the half people forget to test)¶
A backup you've never restored is a hope, not a backup. Restore the snapshot you just made into a new PVC so you can compare it without touching the original:
This Restore section was applied with the bundle, but its source.snapshotRef.name is a placeholder until you point it at the Snapshot CR name from Step 5 (here app-data-manual-abc12):
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Restore
metadata:
name: app-data-verify
namespace: demo
spec:
source:
# Object-store backends name an explicit snapshot: paste the Snapshot CR
# name from `kubectl kopiur snapshots list` (or the manual Snapshot above).
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
Set that name, re-apply the bundle (re-applying is idempotent — only the changed Restore updates), then watch it come up:
$ kubectl apply -f deploy/examples/getting-started.yaml
$ kubectl -n demo get restore -w
NAME PHASE AGE
app-data-verify Resolving 2s
app-data-verify Restoring 8s
app-data-verify Completed 37s
Completed means the data landed in app-data-restored. Mount that PVC in a pod and confirm your files are there — that's the real proof the round-trip works.
Step 7 — Put it on a schedule¶
Manual backups prove the pipeline; a SnapshotSchedule makes it routine. It creates Snapshot CRs on a cron, with deterministic jitter so replicas agree and load spreads.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
name: app-data-nightly
namespace: demo
spec:
policyRef:
name: app-data # the recipe to invoke each firing
schedule:
# `H` = a deterministic per-schedule minute (hashed from the schedule's UID),
# so many 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.
jitter: 30m
# GitOps-friendly: applying this manifest does NOT take an immediate backup.
runOnCreate: false
It was applied with the bundle; confirm it's registered and check the firing it pinned:
$ kubectl -n demo get snapshotschedule
NAME CONFIG SCHEDULE SUSPENDED AGE
app-data-nightly app-data H 2 * * * false 3s
# the controller pins the next firing into status:
$ kubectl -n demo get snapshotschedule app-data-nightly \
-o jsonpath='{.status.nextSchedule.at}'
2026-06-07T02:17:00Z
That's a complete, recurring, restore-tested backup. 🎉
What just happened (and where to go next)¶
You created a Repository (where), a SnapshotPolicy (what), invoked it with a Snapshot (one snapshot), proved it with a Restore, and automated it with a SnapshotSchedule (when). Maintenance — the periodic kopia maintenance that reclaims space — was set up for you automatically the moment the repository existed; you don't have to do anything for it.
Wait for Ready (kstatus / GitOps)
Every reconciled CRD exposes standard metav1.Conditions (Ready, plus Reconciling/Stalled) and status.observedGeneration, so Flux wait/healthChecks, Argo CD health, and plain kubectl wait work natively:
From here:
- Concepts — the why behind what you just did: dedup, the identity model, the three-resource split, and one-shared-repository guidance.
- Repositories & backends — point Kopiur at Azure, GCS, B2, a NAS (filesystem/SFTP/WebDAV), or rclone; and share one repo across namespaces with
ClusterRepository. - Backups & schedules — multi-PVC selectors, hooks (quiesce a database before snapshotting), retention tuning,
deletionPolicy. - Restores — point-in-time restore, deploy-or-restore (GitOps), and restoring snapshots Kopiur didn't create.
- Maintenance — what runs, when, and how shared repositories coordinate.
- Examples — the complete, apply-ready manifest ladder covering the patterns above.
- Troubleshooting — when a step above doesn't go green.
Tearing down the walkthrough¶
$ kubectl -n demo delete snapshotschedule app-data-nightly
$ kubectl -n demo delete restore app-data-verify
$ kubectl -n demo delete snapshot --all # finalizer also deletes the snapshots
$ kubectl -n demo delete snapshotpolicy app-data
$ kubectl -n demo delete repository primary
Deleting a Snapshot deletes its snapshot
A scheduled/manual Snapshot defaults to deletionPolicy: Delete, so removing the CR runs kopia snapshot delete via a finalizer. Use Retain (or Orphan) if you want the CR gone but the snapshot kept — see Backups → deletionPolicy.