Kopiur is a Kopia-native Kubernetes backup operator, written in Rust on kube-rs. This guide covers installing the operator with the Helm chart and verifying it.
The chart is published as an OCI artifact at oci://ghcr.io/home-operations/charts/kopiur, and that is the preferred way to install. Every published version is cosign-signed and ships all three component images, controller, webhook and mover, pinned by digest to the exact release build.
The in-repo chart at deploy/helm/kopiur is the development copy. Its image digests are empty and its tags float, so use it only when you are working from a checkout.
Status: alpha. API group kopiur.home-operations.com, version v1alpha1. The CRD surface may still change between releases.
Kubernetes 1.24 or later. The deploy-or-restore volume-populator path, meaning a Restore plus a PVC spec.dataSourceRef, relies on the AnyVolumeDataSource feature, available from 1.24.
Helm 3 or 4.
A kopia repository backend you can reach: S3 or MinIO, Azure Blob, GCS, B2, filesystem on a PVC, SFTP, WebDAV, or rclone.
A CSI snapshot stack, meaning a snapshot-controller plus a VolumeSnapshotClass for your driver, for the defaultcopyMethod: Snapshot. Many distributions bundle it, including EKS, GKE, AKS, Talos and k3s add-ons. Where yours does not, install the home-operations snapshot-controller chart from oci://ghcr.io/home-operations/charts/snapshot-controller. You do not need it if every SnapshotPolicy sets copyMethod: Direct. See Copy methods → What it requires.
(Optional)cert-manager, only if you prefer it to manage the admission webhook’s certificate. It is not required: by default the operator manages the webhook cert itself. See Webhook TLS.
(Optional)volume-data-source-validator, recommended alongside CSI populators so a malformed dataSourceRef surfaces as an event rather than a PVC that is quietly stuck.
(Optional)Prometheus Operator, if you want the chart’s ServiceMonitor.
Point your GitOps tool at the same OCI chart. Never point it at the in-repo deploy/helm/kopiur path, whose image digests are empty and whose tags float: a git-sourced install renders image tags from the chart’s appVersion, so a checkout can pull a tag that was never published. The published chart pins all three images by digest.
The OCIRepository and HelmRelease are namespaced, so kopiur-system must exist before they apply. That is why the Namespace is included above: Flux’s install.createNamespace only creates the release’s target namespace, not the one the HelmRelease object itself lives in. In a real Flux repository the namespace usually comes from the parent Kustomization; it is kept here so the snippet applies on its own.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kopiur
namespace: argocd
spec:
project: default
source:
repoURL: ghcr.io/home-operations/charts# no oci:// prefix here
There is nothing to configure; this is the helm install above. The operator grants itself the minimal extra RBAC it needs: writing its one serving Secret, and patching the caBundle of its two webhook configurations, scoped to those resource names.
Cluster is the default so that a shared platform repository, a ClusterRepository referenced by many tenant namespaces, works out of the box. A namespace-scoped Role silently disables the cluster-scoped ClusterRepository kind. See deploy/examples/02-cluster-repository.yaml.
Choose namespaced as the explicit least-privilege opt-down for a single-team install.
In namespaced scope the controller’s watches are narrowed to the release namespace, to match the Role-only RBAC; the chart passes --namespace={{ .Release.Namespace }}. Cluster-scoped kinds are skipped entirely: ClusterRepository is not reconciled, and the privileged-movers namespace opt-in check fails open, because the operator is already confined to admin-chosen namespaces there.
The stream-source opt-in fails closed there instead — pods/exec is a far larger grant than an elevated container — so stream sources are refused under a namespaced install until you set rbacNamespaceReadForStreamSources: true, which adds a supplementary ClusterRole granting get on namespaces alone. pvc and nfs sources are unaffected.
To pull the Kopiur images from a private registry, set imagePullSecrets (or global.imagePullSecrets) to the registry Secret names. They apply to the controller, webhook and console pods, and to every mover pod the operator starts.
The chart’s defaults are sized for a typical homelab to mid-size cluster. A handful of top-level values tune the controller’s runtime footprint and its load on the API server. The full rationale is in the developer docs.
Value
Env var
Default
What it does
workerThreads
KOPIUR_WORKER_THREADS
2
Tokio worker threads. The controller is I/O-bound; raise only for a reconcile-heavy deployment.
streamingLists
KOPIUR_STREAMING_LISTS
true
Stream cluster-wide re-lists via the WatchList API (lower apiserver + controller memory). Auto-downgrades to paged lists when the apiserver answers with a pre-1.32 version; a startup probe that fails to answer keeps the configured value, since a transport failure is not evidence about the server’s version.
reconcileConcurrency
KOPIUR_RECONCILE_CONCURRENCY
8
Per-controller cap on concurrent reconciles. Bounds API-server load and file descriptors during re-list storms and API-server outages. 0 = unbounded (not recommended).
maxConcurrentDeleteJobs
KOPIUR_MAX_CONCURRENT_DELETE_JOBS
0 (no cluster-wide cap)
Opt-in backstop on concurrent snapshot-delete batch Jobs across all repositories; each repository is already limited by spec.concurrency.maxConcurrentDeleteJobs (default 1).
maxConcurrentJobs
KOPIUR_MAX_CONCURRENT_JOBS
0 (uncapped)
Opt-in cluster-wide backstop on concurrent pooled mover Jobs (backups, restores, and the source side of either replication) across all repositories. The per-repository spec.concurrency.maxConcurrentJobs is the primary knob; a run must satisfy both.
leaderElection.flowSchema.enabled
—
true
Give the controller’s leader-election Lease its own API Priority and Fairness lane. See below.
The 9 CRDs ship in the chart’s special crds/ directory. Helm treats that directory specially: helm install installs the CRDs, but helm upgrade never touches them. There is no toggle for this.
Caution: because helm upgrade skips the crds/ directory, a helm-CLI upgrade that carries a schema change does not apply it. You must apply the new CRDs yourself:
Terminal window
# Server-side apply is required: the SnapshotPolicy CRD embeds a full JobSpec
# (runJob hook) and is too large for the client-side last-applied annotation.
kubectlapply--server-side-fdeploy/crds/
A GitOps flow (Flux or Argo with a CreateReplace sync policy) applies CRD changes automatically. If you manage CRDs entirely out of band, just apply deploy/crds/ yourself, since helm install skips the ones that already exist.
Skipping this is usually silent, not loud. When a release adds a new spec field, an apiserver still running the old schema prunes that field out of every object you apply. The object admits cleanly, kubectl get -o yaml shows no trace of the field, and the feature you configured simply never happens. If a newly documented field seems to be ignored, check the live CRD before anything else with kubectl get crd <plural>.kopiur.home-operations.com -o yaml | grep <field>. kubectl kopiur doctor flags known-stale schemas too.
The CRDs and RBAC shipped by the chart are generated by cargo xtask gen-crds and cargo xtask gen-rbac, and checked in under deploy/crds/ and deploy/rbac/. Those xtasks are the source of truth.
Upgrading from 0.5.x to 0.6.0 is a special case. The CRDs used to be release-owned templates and moved into this crds/ directory, so the crossing removes and re-installs them, which cascade-deletes your custom resources, unless you pin them first. Read Upgrading → 0.5.x → 0.6.0 before you upgrade.
The controller serves /metrics, /healthz and /readyz on its single port, set by metrics.port and defaulting to :8081. The webhook serves /metrics on its TLS port, set by webhook.port and defaulting to :8443. All metrics are under the kopiur_ namespace.
The controller and webhook default to the dual-stack wildcard bind, [::]:8081 and [::]:8443, which serves both IPv4 and IPv6 kubelets and API servers, so probes work out of the box on either. The chart renders [::]:<port> into KOPIUR_HTTP_ADDR for the controller and KOPIUR_WEBHOOK_ADDR for the webhook. On a host where IPv6 is disabled in the pod network namespace a [::] bind fails outright; force an IPv4-only bind by adding KOPIUR_HTTP_ADDR=0.0.0.0:8081 through the controller’s extraEnv. See Helm chart values → Controller Deployment for the full detail.
The controller’s metrics Service is always created. The :8081 listener hosts /metrics alongside the probes, so there is nothing to disable.
monitoring.serviceMonitor.enabled=true creates a Prometheus-Operator ServiceMonitor, which requires the Prometheus-Operator CRDs. monitoring.prometheusRule.enabled=true ships the kopiur alert rules.
monitoring.dashboards.enabled=true ships the Grafana dashboard as a sidecar-discoverable ConfigMap, sourced from deploy/dashboards/kopiur.json. Set monitoring.dashboards.grafanaOperator.enabled=true to render a grafana-operatorGrafanaDashboard object from the same JSON instead, and use monitoring.dashboards.grafanaOperator.matchLabels to select the Grafana instance.
observability.otlp.enabled=true, with observability.otlp.endpoint set, also exports OTLP traces, logs and a metrics push from the controller, the webhook and mover Jobs. It is off by default.
Kopiur ships an optional in-cluster web console, kopiur-ui, off by default. It needs three things, and refuses to render without them:
installScope: cluster — impersonation and the console’s cluster-wide reads cannot be expressed in a namespaced Role.
An identity source — the header your authenticating proxy sets, or an explicit anonymous identity.
In header mode, a proxy shared secret, so the console can tell your proxy’s requests from anything else in the cluster that can reach its Service.
The chart renders the console, its Service and its roles. It does not render an Ingress, and it binds nobody unless you list them in ui.rbac.bindings, so a freshly-enabled console reports Forbidden on every screen until you bind someone — which is the correct first experience, not a broken install.
Terminal window
# You create the shared token first; the chart only mounts it.
The last two lines grant the platform group full use of the console. The header names are oauth2-proxy’s. Behind Envoy Gateway, use the names from your claimToHeaders instead and also set ui.auth.groupsFormat=base64Json — Envoy base64-encodes the groups claim. With Envoy Gateway has the complete wiring.
Read Web console (kopiur-ui) before exposing it: Identity and access walks through getting a group from your IdP to a role step by step, and the page also covers the trust model, the four roles, what granting the file browser really hands over, and worked oauth2-proxy and Envoy Gateway deployments.