Skip to content

Getting started

This is the end-to-end walkthrough: from a cluster with nothing installed to a verified backup and a verified restore. It holds your hand at every step and shows you exactly what to look for, so you know each one worked. Budget about 15 minutes.

If you only want the install reference, meaning every Helm value, the scopes, and the certificate options, see Installation. This page is the guided first run.

Want the long version, with the reasoning behind every value, a NAS track, and the kubectl kopiur plugin woven in? That is the Complete walkthrough.

  • A Kubernetes cluster, 1.24 or later, and kubectl pointed at it.
  • Helm 3 or 4.
  • A storage backend kopia can reach. This guide uses S3 or an S3-compatible store, such as AWS S3, MinIO, RustFS or Ceph RGW. Any of the eight backends works the same way; only the Repository changes.
  • A PersistentVolumeClaim with some data in it to back up. The walkthrough assumes one named app-data in a namespace called demo.

Install the chart into its own namespace. By default the operator manages the webhook’s serving certificate itself, so no cert-manager is required. If you prefer cert-manager or a hand-supplied certificate, see Installation → Webhook TLS.

Terminal window
$ helm install kopiur oci://ghcr.io/home-operations/charts/kopiur \
--namespace kopiur-system --create-namespace

Verify the operator is up and the 9 CRDs are registered:

Terminal window
$ 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 ...
snapshotreplications.kopiur.home-operations.com ...
restores.kopiur.home-operations.com ...

Nine CRDs and two ready Deployments means the operator is live.

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, so demo here, because the mover loads it with envFrom, which only reads the local namespace. See Movers, RBAC & credentials for the full reasoning.

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 custom resource in one shot. The rest of the steps just watch each piece come up.

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:

Terminal window
$ kubectl -n demo get repository primary -w
NAME PHASE BACKEND AGE
primary Initializing S3 5s
primary Ready S3 12s

If it sticks in Pending or Failed, read the reason. Kopiur tells you exactly what is wrong:

Terminal window
$ kubectl -n demo describe repository primary # see Conditions + Events

The common causes are wrong keys, an unreachable endpoint, or a bucket that does not exist while create.enabled: false. See Troubleshooting.

Step 4 — Write the recipe (SnapshotPolicy)

Section titled “Step 4 — Write the recipe (SnapshotPolicy)”

Now describe what to back up and how long to keep it. Retention is GFS, meaning grandfather-father-son, and it 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 is registered:

Terminal window
$ kubectl -n demo get snapshotpolicy
NAME REPOSITORY AGE
app-data primary 3s

A SnapshotPolicy runs nothing yet, because it is the recipe. Next we invoke it.

Step 5 — Take your first backup (and watch it work)

Section titled “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 such as app-data-manual-abc12, and kubectl apply cannot track a server-named object.

Run create against the bundle. The namespace, Secret and other resources already exist, so kubectl reports them unchanged, and the Snapshot is the one new object it mints:

Terminal window
$ 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:

Terminal window
$ 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)

Section titled “Step 6 — Restore it (the half people forget to test)”

A backup you have 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 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, which is idempotent so only the changed Restore updates, then watch it come up:

Terminal window
$ 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 is the real proof the round trip works.

Manual backups prove the pipeline; a SnapshotSchedule makes it routine. It creates Snapshot objects 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 is registered, and check the firing it pinned:

Terminal window
$ 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 is a complete, recurring, restore-tested backup. 🎉

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 do not have to do anything for it.

From here:

  • Concepts is the why behind what you just did: dedup, the identity model, the three-resource split, and one-shared-repository guidance.
  • Repositories & backends points Kopiur at Azure, GCS, B2, a NAS through filesystem, SFTP or WebDAV, or rclone, and shares one repository across namespaces with ClusterRepository.
  • Backups & schedules covers multi-PVC selectors, hooks such as quiescing a database before snapshotting, retention tuning, and deletionPolicy.
  • Restores covers point-in-time restore, deploy-or-restore for GitOps, and restoring snapshots Kopiur did not create.
  • Maintenance covers what runs, when, and how shared repositories coordinate.
  • Examples is the complete, apply-ready manifest ladder covering the patterns above.
  • Troubleshooting is for when a step above does not go green.
Terminal window
$ 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