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 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 Repository is where snapshots are stored (your S3 bucket, NAS, B2…);
  • a SnapshotPolicy is the recipewhat to back up. It is idempotent and runs nothing on its own;
  • a Snapshot is an invocation — one snapshot, as a Kubernetes object. It is the universal trigger (created by a schedule, by kubectl, or by automation);
  • a SnapshotSchedule is the cronwhen the recipe runs. It creates Snapshot CRs 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 kubectl pointed 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 Repository changes.
  • A PersistentVolumeClaim with some data in it to back up. The walkthrough assumes one named app-data in a namespace called demo.

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:

$ kubectl apply -f deploy/examples/getting-started.yaml

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?

$ kubectl -n demo create secret generic repo-creds \
    --from-literal=AWS_ACCESS_KEY_ID='REPLACE_ME' \
    --from-literal=AWS_SECRET_ACCESS_KEY='REPLACE_ME' \
    --from-literal=KOPIA_PASSWORD="$(openssl rand -base64 24)"

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:

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

(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:

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

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:

$ kubectl -n demo wait --for=condition=Ready repository/primary --timeout=2m
$ kubectl -n demo wait --for=condition=Ready snapshotpolicy/app-data --timeout=2m

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.