Skip to content

Scenario 01 — Protect a stateful app, consistently

The everyday case. You run a database on a PVC. You want nightly backups that the database can actually be restored from, and a sane retention window. This is the usual reason to install Kopiur, and it takes four resources in one namespace.

It goes one step past example 01. It adds hooks that quiesce the database around the snapshot, so the captured bytes are application-consistent, not just crash-consistent.

What you'll deploy

Four objects, all in the app's namespace, because that's where the mover Job runs:

  • a Secret holding the backend credentials and the repository password;
  • a Repository, a new S3 repository for this app;
  • a SnapshotPolicy, the recipe: source PVC, copy method, hooks, retention;
  • a SnapshotSchedule, the nightly cron.

The values you'll actually change

Field Where What it does
backend.s3.bucket / prefix / endpoint / region Repository Points at your object store.
KOPIA_PASSWORD the Secret Encrypts the repository. Lose it, lose the backups.
sources[].pvc.name SnapshotPolicy The PVC to back up.
hooks.*.workloadExec SnapshotPolicy The commands that quiesce and unquiesce your app. Swap the Postgres ones for your database's equivalents.
retention.keep* SnapshotPolicy The GFS window: how many daily, weekly, and monthly snapshots to keep.
schedule.cron / jitter SnapshotSchedule When it runs. H picks a stable minute for you.

Why hooks instead of just a snapshot?

copyMethod: Snapshot is the default. It needs the CSI snapshot stack and is the right choice for a database like this, and it already gives you a crash-consistent point-in-time copy.

The beforeSnapshot and afterSnapshot hooks add application consistency on top. They run inside the workload to tell the database a backup is starting, so what's on disk at snapshot time is a clean, restorable state.

A hook failure aborts the backup unless you set continueOnFailure: true. We set it on the after hook so the database never gets stuck in backup mode.

apiVersion: v1
kind: Secret
metadata:
  name: postgres-repo-creds
  namespace: billing
type: Opaque
stringData:
  AWS_ACCESS_KEY_ID: "REPLACE_ME"
  AWS_SECRET_ACCESS_KEY: "REPLACE_ME"
  # Lose this and the backups are unrecoverable — kopia cannot decrypt without it.
  KOPIA_PASSWORD: "choose-something-long-and-random"
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Repository
metadata:
  name: postgres-primary
  namespace: billing
spec:
  backend:
    s3:
      bucket: my-backups
      prefix: billing/postgres/
      endpoint: s3.us-east-1.amazonaws.com
      region: us-east-1
      auth:
        secretRef:
          name: postgres-repo-creds
  encryption:
    passwordSecretRef:
      name: postgres-repo-creds
      key: KOPIA_PASSWORD
  create:
    enabled: true # this is a NEW repo — initialize it on first connect
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotPolicy
metadata:
  name: postgres-data
  namespace: billing
spec:
  repository:
    name: postgres-primary # kind defaults to Repository (same namespace)
  sources:
    - pvc:
        name: postgres-data
  # ── How the volume is captured ────────────────────────────────────────────
  # Snapshot takes a point-in-time CSI VolumeSnapshot, then kopia reads THAT — no
  # app downtime, consistent on disk. Combined with the hooks below you also get
  # application consistency. (copyMethod defaults to Snapshot — shown explicitly
  # for teaching — and needs the CSI snapshot stack + a VolumeSnapshotClass for
  # your driver, ideal for databases like this; Direct reads the live volume and
  # works on any storage, but set it explicitly if you have no CSI snapshots.)
  copyMethod: Snapshot
  # volumeSnapshotClassName: csi-hostpath-snapclass   # set if not the cluster default
  # ── Quiesce the database around the snapshot ──────────────────────────────
  # Hooks run IN THE WORKLOAD (not the mover): tell Postgres a backup is starting
  # before the snapshot, and that it finished after. A hook failure aborts the
  # backup unless continueOnFailure: true.
  hooks:
    beforeSnapshot:
      - workloadExec:
          podSelector:
            matchLabels:
              app: postgres
          container: postgres
          command: ["/bin/sh", "-c", 'psql -U postgres -c "SELECT pg_backup_start(''kopiur'');"']
          timeout: 2m
    afterSnapshot:
      - workloadExec:
          podSelector:
            matchLabels:
              app: postgres
          container: postgres
          command: ["/bin/sh", "-c", 'psql -U postgres -c "SELECT pg_backup_stop();"']
          # Run even if something upstream failed, so the DB never stays in
          # backup mode.
          continueOnFailure: true
  # ── How long to keep snapshots (GFS — the only successful-retention driver) ──
  retention:
    keepDaily: 14
    keepWeekly: 6
    keepMonthly: 3
---
apiVersion: kopiur.home-operations.com/v1alpha1
kind: SnapshotSchedule
metadata:
  name: postgres-data-nightly
  namespace: billing
spec:
  policyRef:
    name: postgres-data
  schedule:
    # Jenkins-style H pins a deterministic per-schedule minute; jitter spreads
    # load so many schedules don't stampede the repository at once.
    cron: "H 2 * * *"
    jitter: 30m
    runOnCreate: false # GitOps-friendly: don't fire the instant it's applied

Verify it worked

The Repository should reach Ready first:

$ kubectl get repository -n billing
NAME               PHASE   AGE
postgres-primary   Ready   30s

Then fire one backup by hand before you trust the schedule. That confirms the whole chain: recipe, then mover, then snapshot. This Snapshot manifest ships with the example, in its manual-snapshot section:

# Verification: fire ONE backup by hand to confirm the whole chain (recipe →
# mover → snapshot) before trusting the nightly schedule. generateName lets you
# re-apply this repeatedly to take another test snapshot.
apiVersion: kopiur.home-operations.com/v1alpha1
kind: Snapshot
metadata:
  generateName: postgres-data-test-
  namespace: billing
spec:
  policyRef:
    name: postgres-data
$ kubectl apply -f deploy/examples/scenarios/01-protect-stateful-app.yaml

$ kubectl get snapshots -n billing -w
NAME                       PHASE       ORIGIN   SNAPSHOT    AGE
postgres-data-test-x9f     Running     manual               7s
postgres-data-test-x9f     Succeeded   manual   k1f1ec0a8   44s

A SNAPSHOT id on a Succeeded backup means the data is in the repository. From here the SnapshotSchedule takes over nightly.

See also