Skip to content

Helm chart values

This page is a guided tour of the Kopiur chart's values.yaml — what every setting does, which handful you actually change, and the trade-offs behind the defaults. For installing the chart (namespaces, webhook TLS modes, CRD lifecycle, the quickstart), see Installation; this page is the reference for the knobs.

Single source

Every YAML block below is pulled directly from the chart's real deploy/helm/kopiur/values.yaml at build time (MkDocs snippets), so the documented defaults can never drift from the file Helm actually renders. The whole annotated file is also inlined at the bottom of this page.

The shape of the chart

Kopiur ships three images wired into two Deployments plus a per-Job image:

  • controller — the operator (reconcilers); serves /metrics, /healthz, /readyz.
  • webhook — a separate axum admission Deployment (validating + mutating).
  • mover — not a Deployment. The controller stamps this image into every Snapshot / Restore / Maintenance Job it creates.

The controller is the chart's primary component, so its knobs live at the root (unprefixed): replicaCount, resources, nodeSelector, podSecurityContext, image, and so on all configure the controller. The two auxiliary components get their own named blocks: webhook: (a full Deployment surface) and mover: (the per-Job image only). So a root-level workload key applies to the controller alone. You configure the controller and webhook independently because they have different resource profiles and lifecycles.

The five values most people actually set

  1. installScopecluster (default) or namespaced (least-privilege opt-down; disables ClusterRepository).
  2. image.tag (controller) / mover.image.digest — pin what runs (digest-pin the mover in prod).
  3. webhook.tls.modeself (default), cert-manager, or manual.
  4. monitoring.serviceMonitor.enabled / monitoring.dashboards.enabled — wire up Prometheus + Grafana.
  5. observability.otlp.enabled — add OTLP traces/logs/metrics-push on top of the pull endpoint.

Everything else has a sensible default. The sections below cover them all.

Naming overrides

# -- Override the chart name used in resource names (defaults to .Chart.Name = "kopiur").
nameOverride: ""
# -- Override the full release-qualified name (defaults to "<release>-kopiur").
fullnameOverride: ""

Standard Helm escape hatches. nameOverride changes the chart-name component of generated resource names; fullnameOverride replaces the whole <release>-kopiur prefix. Leave both empty unless you're fitting Kopiur into an existing naming scheme.

Images

Each of the three images is configured next to the component it belongs to, and each repository is a full registry + path string:

  • controller — the root image block (image.repository, image.tag, image.digest, image.pullPolicy).
  • webhookwebhook.image.* (its own webhook.image.pullPolicy).
  • movermover.image.* (its own mover.image.pullPolicy).
# Charts published to oci://ghcr.io/home-operations/charts ship all three
# digests pinned to the release images (the in-repo defaults stay empty and
# float on tags); on a published chart set digest: "" to make tag effective.
image:
  # -- Full controller image repository (registry + path).
  repository: ghcr.io/home-operations/kopiur-controller
  # -- Defaults to .Chart.AppVersion when empty.
  tag: ""
  # -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
  digest: ""
  # -- Image pull policy for the controller.
  pullPolicy: IfNotPresent

Each image takes a tag (defaults to the chart's appVersion when empty) or a digest. mover.image.pullPolicy sets the imagePullPolicy on every mover Job pod the controller creates (Always / IfNotPresent / Never); when unset, the controller infers IfNotPresent whenever an explicit mover image is configured (so a pinned, e.g. locally-loaded, image is never re-pulled) and otherwise leaves the cluster default in charge.

Digest-pin the mover in production

The mover image runs your data-protection Jobs. A floating :latest (or any mutable tag) means a re-pull could silently change what runs during a backup or restore. Set mover.image.digest to a sha256:… pin — when digest is set it wins over tag — so a Job is always byte-for-byte reproducible. The same advice applies to the controller and webhook, but the mover is the one that touches your data.

imagePullSecrets (at the root, concatenated with global.imagePullSecrets) is applied to the controller/webhook pods and the mover Jobs, so a private registry only needs configuring once.

Install scope & CRDs

# =============================================================================
# Install scope
# =============================================================================
# -- "cluster" (default) or "namespaced".
#   cluster    — RBAC is a ClusterRole/ClusterRoleBinding. The operator manages
#     kopiur.home-operations.com resources cluster-wide AND reconciles
#     ClusterRepository. This is the default because a namespace-scoped Role
#     silently disables the ClusterRepository kind (a cluster-scoped resource is
#     out of a Role's reach), turning kopiur into a shared-backup-tier operator
#     that can't do shared backup tiers.
#   namespaced — RBAC is a namespace-scoped Role/RoleBinding: the operator
#     manages resources only in its own namespace and ClusterRepository is NOT
#     reconciled. Choose this as the explicit least-privilege opt-down for a
#     single-team install where the reduced blast radius is worth losing
#     cluster-scoped repositories.
installScope: cluster
installScope RBAC Manages ClusterRepository
cluster (default) ClusterRole + ClusterRoleBinding cluster-wide reconciled
namespaced Role + RoleBinding the release namespace only not reconciled

cluster is the default because a namespace-scoped Role silently disables the ClusterRepository kind — a cluster-scoped resource is out of a Role's reach, so a namespaced install turns Kopiur into a shared-backup-tier operator that can't do shared backup tiers. Choose namespaced as the explicit least-privilege opt-down for a single-team install where the reduced blast radius is worth losing cluster-scoped repositories.

How the CRDs are installed

The 8 CRDs ship in the chart's special crds/ directory: helm install installs them, but helm upgrade never touches them (a Helm rule for the crds/ directory). For a helm-CLI upgrade that carries a schema change you must apply the new CRDs yourself:

$ kubectl apply -f deploy/crds/

A GitOps flow (CreateReplace sync) applies them automatically. There is no install-time toggle anymore — anyone managing CRDs out of band just applies deploy/crds/ and Helm skips the ones that already exist. See Installation → CRD lifecycle.

Feature permissions

A couple of opt-in features need the operator to write Secrets in the namespaces it manages, so each is gated behind a Helm flag — the chart does not grant cluster-wide secrets write by default (least privilege). The flag names match the CRD field that triggers them.

    path: /readyz
    port: metrics
  initialDelaySeconds: 5
  periodSeconds: 10

# =============================================================================
# ServiceAccount
# =============================================================================
serviceAccount:
  # -- Create the ServiceAccount. Disable to bring your own.
  create: true
  # -- Name to use; defaults to the chart fullname when empty.
  name: ""
  # -- Mount the ServiceAccount token into the controller/webhook pods.
  automount: true
CRD field you set… …needs this Helm flag Grants secrets
spec.credentialProjection features.credentialProjection.enabled create, patch
spec.server (kopia web-UI) features.kopiaUi.enabled create, patch, delete

A real blast-radius trade-off

create/delete cannot be scoped to a Secret name, so enabling either flag lets the operator write (and, for kopiaUi, delete) a Secret in any namespace it manages. Leave them false to keep secrets RBAC read-only. If you enable the feature in a CR but forget the flag, the resource's .status surfaces an actionable 403 naming the exact flag to set. See Feature permissions for the full mapping and the symptom→fix loop.

ServiceAccount

# =============================================================================
# Controller port & probes
# =============================================================================
metrics:
  # -- The controller's single operational port. Rendered into KOPIUR_HTTP_ADDR
  # as "[::]:<port>" (dual-stack wildcard), co-hosting /metrics, /healthz and
  # /readyz. The metrics Service and the probes both target this port.
  port: 8081

Set serviceAccount.create: false to bring your own. The annotations map is where IRSA / GKE Workload Identity role bindings go, so the operator (and the mover Jobs that inherit it) can authenticate to cloud object storage without a static credential Secret. serviceAccount.automount (default true) controls whether the token is mounted into the controller/webhook pods.

Controller Deployment

The controller's knobs live at the root of the values file (no controller. prefix): a root-level workload key applies to the controller alone.

# =============================================================================
# Controller runtime behavior
# =============================================================================
# -- Number of controller replicas. >1 enables HA via leader election; only the
# elected leader reconciles, so deterministic jitter keeps schedules identical
# across replicas and across failover. Pair >1 with podDisruptionBudget and
# topologySpreadConstraints below to make it genuinely highly-available.
replicaCount: 1
# -- Enable leader election. Required when replicaCount > 1; harmless at 1.
leaderElection:
  enabled: true
# -- Tokio worker threads for the controller runtime. The controller is I/O-bound,
# so a small pool is ample; the runtime default sizes to the host core count
# (ignoring the cgroup CPU quota), over-allocating worker threads — each a stack
# plus a malloc arena — on large nodes, inflating RSS for no throughput gain.
# Raise only for a reconcile-heavy deployment.
workerThreads: 2
# -- Use the Kubernetes WatchList streaming-list API for the controller's
# cluster-wide watches, lowering peak memory during the initial resync by
# streaming pages instead of buffering them (the startup burst the resources
# note below warns about). On by default: WatchList is GA in Kubernetes 1.34
# (beta 1.32/1.33) and the chart's kubeVersion floor is 1.32. Set `false` if
# your apiserver has the WatchList feature gate disabled — the watches degrade
# to paged lists either way, but turning it off skips the feature probe.
streamingLists: true
# @schema
# type: integer
# minimum: 0
# @schema
# -- 0 = uncapped (default); set to bound concurrent snapshot-delete batch
# Jobs across repositories (mass-deletion protection). Batching itself is the
# primary protection — one Job per repository per accumulation window, not
# one per Snapshot — so this cap is an opt-in backstop for a
# resource-constrained cluster, not a load-bearing safety mechanism: a small
# cap risks head-of-line-blocking every OTHER repository's deletions behind
# one slow/failing one. It does NOT bound how many Snapshots one batch Job
# deletes, nor does it gate whether a deletion is allowed at all — that's
# deletionProtection.threshold on the Repository/ClusterRepository.
maxConcurrentDeleteJobs: 0
# @schema
# type: integer
# minimum: 0
# maximum: 65535
# @schema
# -- Per-controller cap on concurrently running reconciles (the operator runs
# 8 controllers, so the process-wide worst case is 8x this). Bounds API-server
# load and file descriptors during re-list storms and API-server outages —
# unbounded reconcile concurrency is what let an apiserver flap exhaust the
# controller's fd table (EMFILE) within seconds. The default 8 clears a
# few-hundred-object re-list in seconds while keeping slow reconciles (hooks,
# in-process kopia ops) from starving a controller. 0 = unbounded (the
# pre-fix behavior; not recommended).
reconcileConcurrency: 8
# -- Extra CLI args appended to the controller container.
extraArgs: []
# -- Extra environment variables for the controller container.
extraEnv: []

# =============================================================================
# Controller workload & scheduling  (controller only — webhook has its own)
# =============================================================================
# -- Resource requests for the controller pod.
# No limits by default. No CPU limit (CPU throttling on an operator only adds
# reconcile latency; the request reserves a fair share). No memory limit either:
# the controller's RSS can *burst* on startup/restart, not just steady state
# (~120Mi) — on (re)start it reconciles every existing resource at once, spawning
# concurrent in-process `kopia` subprocesses (whose RSS counts against this
# container's cgroup) to list/connect a repository that may hold many snapshots.
# A memory limit that doesn't cover that burst OOMKills the controller, which
# then crash-loops (OOM -> restart -> re-reconcile burst -> OOM). Set a limit
# only if you've measured your own ceiling. See crates/e2e/tests/lifecycle.rs.
resources:
  requests:
    cpu: 50m

The operator itself. The settings worth knowing:

  • replicaCount + leaderElection — run more than one replica for HA. With leaderElection.enabled (the default) the replicas elect a leader via a coordination.k8s.io/v1 Lease in the release namespace (named after the release): only the Lease holder runs reconcilers, while standby replicas stay Ready (probes and /metrics are served by every replica) and take over within ~15s (the lease duration) if the leader dies — or immediately on a graceful shutdown, where the outgoing leader releases the Lease so rolling upgrades don't stall reconciliation. A leader that loses its Lease exits and re-enters the election on restart — fail-fast beats a split-brain double-reconcile. If the leases RBAC is missing (e.g. a new image under an old chart), the controller logs a loud error and runs without election rather than crash-looping. Kopiur's deterministic jitter (derived from (scheduleUID, slot)) keeps schedules identical across replicas and across failover, so HA never doubles or skews a scheduled backup.

Don't run replicas > 1 with leaderElection disabled

leaderElection.enabled: false removes the Lease RBAC and the election entirely — every replica then reconciles concurrently, duplicating mover Jobs and racing status writes. Only disable it at replicaCount: 1.

  • streamingLists — use the Kubernetes WatchList streaming-list API for the controller's cluster-wide watches, lowering peak memory during the initial resync by streaming pages instead of buffering them (the startup burst the memory note below warns about). Default true: WatchList is beta in Kubernetes 1.32/1.33 and GA in 1.34, and the chart's kubeVersion floor is >=1.32.0-0. The controller gates it on the server version at startup and falls back to paged lists below 1.32 either way, so set false only if your apiserver has the WatchList feature gate disabled (turning it off just skips the feature probe).
  • workerThreads — Tokio worker threads for the controller runtime (default 2). The controller is I/O-bound, so a small pool is ample; raise only for a reconcile-heavy deployment.
  • extraVolumes / extraVolumeMounts — the way to make a filesystem backend reachable in-process (hostPath / NFS / PVC), so the controller can run its short idempotent kopia ops. The e2e harness uses a hostPath here.
  • resources — only requests are set by default; there are intentionally no limits (the limits block ships commented out). Uncomment and tune it to your own measured ceiling if you want them.
  • podDisruptionBudget / topologySpreadConstraints — pair these with replicaCount > 1 to make HA genuine: the PDB keeps a voluntary disruption (node drain, cluster upgrade) from taking the controller to zero, and the spread constraints keep both replicas off the same node/zone. Both fall back to their global.* counterparts when left empty.

Why the controller ships with no memory limit

On (re)start the controller reconciles every existing resource at once, spawning concurrent in-process kopia subprocesses (whose RSS counts against this container's cgroup) to list/connect repositories that may hold many snapshots. That makes RSS burst well above steady state (~120Mi). A memory limit that doesn't cover the burst OOMKills the controller, which then crash-loops (OOM → restart → re-reconcile burst → OOM) — so the chart sets no limit by default. If you add one, size it for the burst, not steady state, and measure your own ceiling first. See crates/e2e/tests/lifecycle.rs.

Controller port & probes

podLabels: {}
podAnnotations: {}
# -- PodDisruptionBudget for the controller. Keeps a voluntary disruption (node
# drain, cluster upgrade) from taking the controller to zero replicas. Only
# useful with replicaCount > 1.
podDisruptionBudget:
  enabled: false
  minAvailable: 1

# =============================================================================
# Controller pod security (controller only)
# =============================================================================
# The controller may need a RELAXED context — e.g. runAsUser: 1000 + fsGroup to
# read a filesystem/NFS-backed repository for in-process kopia ops (which is why
# extraVolumes exists on it). The webhook is pure admission and keeps its own
# locked-down context under webhook.* so relaxing the controller never loosens
# the webhook. Non-root uid 65534 (nobody) by default — distroless:nonroot.
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 65534
  runAsGroup: 65534
  fsGroup: 65534
  seccompProfile:
    type: RuntimeDefault
securityContext:
  allowPrivilegeEscalation: false

The controller has a single operational port, metrics.port (default 8081), which co-hosts /metrics, /healthz, and /readyz. The chart renders the dual-stack wildcard bind address [::]:<port> into the KOPIUR_HTTP_ADDR env, which serves both IPv4 and IPv6 kubelets (a wildcard IPv6 bind also accepts IPv4 on Linux when net.ipv6.bindv6only=0, the default), so probes work on either. The metrics Service and the probes both target this port.

Forcing an IPv4-only bind

On a host where IPv6 is disabled in the pod network namespace a [::] bind fails outright. There is no listenAddr value anymore — override the bind address directly by adding KOPIUR_HTTP_ADDR (e.g. 0.0.0.0:8081) through the controller's extraEnv. An unparseable address fails the controller at startup with an actionable error instead of silently falling back to the default.

livenessProbe / readinessProbe are passed through with toYaml, so you can retune timings/thresholds, swap the scheme, or set either to {} to drop the probe.

Admission webhook

The webhook is a separate Deployment + Service; the Service maps 443 → 8443.

The webhook is a full component with its own webhook: block: its own webhook.image, webhook.port (the container port, default 8443; the chart renders [::]:<port> into KOPIUR_WEBHOOK_ADDR and the Service maps 443 → it), webhook.replicaCount, scheduling (webhook.nodeSelector / tolerations / affinity / topologySpreadConstraints), its own webhook.podDisruptionBudget, and its own security context (see Pod security). A root-level workload key never touches it.

  dashboards:
    # -- Create the dashboard (a sidecar ConfigMap by default).
    enabled: false
    # -- Label the Grafana sidecar watches for (key: value). Adjust to your stack.
    label: grafana_dashboard
    labelValue: "1"
    # -- Annotation setting the Grafana folder for the sidecar ConfigMap (optional).
    folderAnnotation: ""
    folder: ""
    # -- Namespace for the dashboard object; defaults to the release namespace.
    namespace: ""
    # -- Extra annotations added to the dashboard object (ConfigMap or CR).
    annotations: {}
    # -- Extra labels added to the dashboard object (ConfigMap or CR).
    labels: {}
    grafanaOperator:
      # -- Render a grafana-operator GrafanaDashboard CR instead of the sidecar ConfigMap.
      enabled: false
      # -- Allow a Grafana in any namespace to import this GrafanaDashboard.
      allowCrossNamespaceImport: true
      # -- Folder to create the dashboard in (Grafana folder name).
      folder: ""
      # -- How often grafana-operator re-checks the dashboard for updates.
      resyncPeriod: "10m"
      # -- spec.instanceSelector.matchLabels — selects which Grafana instance(s) load this dashboard.
      matchLabels: {}

# =============================================================================
# Admission webhook  (its own Deployment + Service + configs)
# =============================================================================
# A separate Deployment + Service, not folded into the controller. Everything
# the webhook workload needs lives here (its own image, port, scheduling,
# security context, PDB, spread, scrape) — a root-level key never touches it.
webhook:
  # -- Deploy the webhook (Deployment + Service + Validating/Mutating configs).
  # When false, validation falls back to the controller's defensive checks only.
  enabled: true
  replicaCount: 1
  # Admission webhook image (same tag/digest rules as the controller `image`).
  image:
    # -- Full webhook image repository (registry + path).
    repository: ghcr.io/home-operations/kopiur-webhook
    # -- Defaults to .Chart.AppVersion when empty.
    tag: ""
    # -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
    digest: ""
    # -- Image pull policy for the webhook.
  • enabled — when false, validation falls back to the controller's defensive checks only. Not recommended; the webhook is what makes invalid states unrepresentable at admission time.
  • failurePolicy: Fail — fail-closed is the default and the right call for a backup operator: if the webhook is down, reject the write rather than silently admit an unvalidated Snapshot. That makes webhook.podDisruptionBudget the most important PDB to enable in HA.
  • webhook.serviceMonitor — the webhook serves /metrics on its TLS port; scraping it needs insecureSkipVerify (it serves a self-signed cert by default). This stays under webhook: (not monitoring:) because it's an HTTPS scrape of the webhook's own port.

Webhook TLS

        - ALL
  # -- Extra environment variables for the webhook container (list of
  # `{name, value}` / `{name, valueFrom}` entries), appended after the
  # operator-managed env. Mirrors the root `extraEnv`.
  extraEnv: []
  # -- Liveness probe for the webhook container. Passed through with `toYaml`
  # (retune timings/thresholds or swap the probe; `{}` drops it). The webhook
  # only serves HTTPS, so `scheme: HTTPS` on the named `https` port.
  livenessProbe:
    httpGet:
      path: /healthz
      port: https
      scheme: HTTPS
    initialDelaySeconds: 5
    periodSeconds: 15
  # -- Readiness probe for the webhook container (same passthrough as
  # livenessProbe; set to `{}` to drop it).
  readinessProbe:
    httpGet:
      path: /readyz
      port: https
      scheme: HTTPS
    initialDelaySeconds: 5
    periodSeconds: 10
  # The webhook serves /metrics on its TLS port (kopiur_webhook_admission_total).
  # This ServiceMonitor stays on the webhook (not under monitoring:) because it
  # is an HTTPS scrape of the webhook's own port — a different scrape config from
  # the controller's plain-HTTP one.
  serviceMonitor:
    # -- Create a ServiceMonitor scraping the webhook's /metrics over HTTPS.
    enabled: false

The webhook always serves TLS (Kubernetes requires HTTPS for admission); webhook.tls.mode only chooses how the serving cert is provisioned:

mode What happens Needs cert-manager?
self (default) Operator mints its own CA + cert, writes the Secret, injects the caBundle, auto-rotates. No
cert-manager cert-manager issues the cert; its ca-injector populates the caBundle. Yes
manual You pre-create the Secret and set webhook.caBundle (base64 PEM) yourself. No

The default self mode needs zero configuration and no external dependency. Full walkthrough with --set commands for each mode: Installation → Webhook TLS.

Monitoring (Prometheus & Grafana)

# =============================================================================
# OpenTelemetry (OTLP) — optional push of traces + logs (and a metrics push)
# =============================================================================
# Separate from `logging` above (the always-on stdout stream): observability.otlp
# is the OPTIONAL push path. When enabled, the controller, webhook, and mover Jobs
# export OTLP to the configured collector (the controller passes the same OTEL_*
# env to movers). Metrics are ALSO always available via the Prometheus /metrics
# pull endpoint; OTLP just adds a push path plus traces and logs. Env var names
# match crates/telemetry/src/env.rs.
observability:
  otlp:
    # -- Enable OTLP export (sets OTEL_EXPORTER_OTLP_ENDPOINT on all components).
    enabled: false
    # -- Collector gRPC endpoint. Required when enabled. Only gRPC is compiled in.
    endpoint: "http://otel-collector.observability.svc:4317"
    # -- OTEL_EXPORTER_OTLP_PROTOCOL (only "grpc" is supported by this build).
    protocol: "grpc"
    # -- OTEL_EXPORTER_OTLP_HEADERS, e.g. "authorization=Bearer xyz". Empty to omit.
    headers: ""
    # -- Fail-fast on telemetry misconfiguration instead of degrading to fmt+pull.
    strict: false
    # -- Extra raw env (e.g. OTEL_TRACES_SAMPLER) added to every component.
    extraEnv: []

# =============================================================================
# Monitoring (Prometheus + Grafana)
# =============================================================================
# The controller-scrape ServiceMonitor, the alerting PrometheusRule, and the
# Grafana dashboard(s), grouped under one umbrella. NOTE: the webhook's own
# ServiceMonitor is NOT here — it lives at webhook.serviceMonitor because it is
# an HTTPS scrape of the webhook's TLS port, a materially different scrape config
# that moves with the webhook component, not this umbrella. The split is
# deliberate.
monitoring:
  serviceMonitor:
    # -- Create a Prometheus-Operator ServiceMonitor scraping the controller's
    # /metrics (plain HTTP). Requires the ServiceMonitor CRD to exist.
    enabled: false
    # -- Scrape interval.
    interval: 30s
    # -- Scrape timeout.
    scrapeTimeout: 10s
    # -- Extra labels (e.g. to match your Prometheus serviceMonitorSelector).
    labels: {}
    # -- Relabelings / metricRelabelings passed through verbatim.
    relabelings: []
    metricRelabelings: []
  prometheusRule:
    # -- Create a Prometheus-Operator PrometheusRule with kopiur alerts.
    enabled: false
    # -- Extra labels (e.g. to match your Prometheus ruleSelector).
    labels: {}
    # -- Age (seconds) after which a SnapshotPolicy's last success is "stale".

All metrics are under the kopiur_ namespace and served via a Prometheus pull endpoint on the controller's port (metrics.port). The controller's metrics Service is always created — that listener co-hosts /metrics with /healthz + /readyz, so there's nothing to disable. The monitoring: block additionally wires up the Prometheus Operator and Grafana:

  • monitoring.serviceMonitor.enabled — create a ServiceMonitor scraping the controller's /metrics (plain HTTP; needs the Prometheus-Operator CRDs); set .labels to match your serviceMonitorSelector. The ServiceMonitor sets honorLabels: true, so each kopiur_* series keeps its own namespace label — the namespace of the CR the metric describes — instead of having it overwritten by the controller's namespace (which Prometheus would otherwise move aside to exported_namespace).
  • monitoring.prometheusRule.enabled — ship the kopiur alert rules. backupStaleAfterSeconds (default 48h) is the age after which a SnapshotPolicy's last success is considered stale. The backup-failure alerts are recovery-aware: KopiurLastBackupFailed fires while a SnapshotPolicy's most recent completed backup failed and resolves automatically the moment a newer backup succeeds; KopiurSnapshotFailed (per-Snapshot) stops firing for a retained Failed Snapshot once its policy's latest backup succeeds — a Snapshot with no policy reference keeps the always-fire behavior instead. KopiurBackupStale still covers the policy that has never succeeded or whose successful Snapshot CRs were all pruned. Both recovery-aware alerts wait for: 10m at severity: warning.
  • monitoring.dashboards.enabled — ship the dashboard. By default it's a sidecar-discoverable ConfigMap (source: deploy/dashboards/kopiur.json); flip monitoring.dashboards.grafanaOperator.enabled to render a grafana-operator GrafanaDashboard CR from the very same JSON instead.

honorLabels: true changes the namespace label

With honorLabels: true, the namespace label on every scraped kopiur_* series is now the CR's namespace rather than the controller's. If you keyed external dashboards or queries on exported_namespace (where the CR namespace previously landed), update them to namespace. The dashboard this chart ships already assumed CR-namespace semantics, so this fixes its $namespace dropdown rather than breaking it.

The webhook's own HTTPS scrape lives separately under webhook.serviceMonitor.

OpenTelemetry (OTLP)

  # never reads them (`--local` is the exception and additionally needs `get
  # secrets`, granted separately). The chart only renders the ClusterRole; bind
  # it to your users/groups with your own (Cluster)RoleBinding.
  browseRole: false

# =============================================================================
# Logging (stdout / `kubectl logs`, always on)
# =============================================================================
# Controls the fmt logging that every component writes to stdout — the stream
# `kubectl logs` shows. The controller passes RUST_LOG + KOPIUR_LOG_FORMAT
# through to mover Jobs, so a mover honors the same level and format. This is a
# chart-wide concern (all three components honor it), which is why it stays a
# named top-level block rather than folding under the controller. Env var names
# match crates/telemetry/src/env.rs. (For pushing logs/traces to a collector,

Off by default. Metrics are always available via the /metrics pull endpoint; turning on OTLP adds a push path plus traces and logs. When enabled, the controller, webhook, and mover Jobs all export to the configured collector (the controller forwards the same OTEL_* env to the movers it spawns). Only gRPC is compiled in, so endpoint must point at the collector's gRPC port (4317).

observability.otlp.strict makes telemetry misconfiguration fail-fast instead of degrading to fmt+pull — leave it false unless you want a broken collector to block startup. See Observability for the full metric list and a sample collector config.

Logging

    enabled: false
  kopiaUi:
    # -- Grant the operator `secrets` create+patch+delete so `spec.server` (the
    # kopia web-UI) works: it creates a generated-auth Secret, mirrors a
    # ClusterRepository's credentials into the server namespace, and deletes both on
    # teardown. SECURITY TRADE-OFF: grants unscoped create/delete on Secrets in any
    # namespace the operator manages.
    enabled: false

Controls the stdout (kubectl logs) logging every component writes. The controller passes RUST_LOG + KOPIUR_LOG_FORMAT through to mover Jobs, so a mover honors the same level and format.

  • levelRUST_LOG-style, default info. Per-target works too: "info,kopia=debug" surfaces kopia's own progress in mover logs.
  • formattext (human-readable, default) or json (one structured object per line for Loki / ELK / Datadog).

Flags & environment variables

You normally configure Kopiur through the Helm values above — the chart turns them into environment variables on the controller and webhook Deployments. Under the hood, every one of those knobs is also a command-line flag on the binary, with the env var as its fallback (flag > env var > built-in default). Run any binary with --help for the full, self-documenting list:

$ kopiur-controller --help   # every knob, its KOPIUR_* env var, and its default
$ kopiur-webhook --help
$ kopiur-mover --help        # ready / serve subcommands + run-once mode

The flags matter in two situations:

  • Running a binary outside the chart (local development, a custom deployment): kopiur-controller --mover-image ghcr.io/… --http-addr '[::]:8081' beats exporting env vars by hand.
  • extraArgs (the controller's) — extra flags are now actually parsed. That cuts both ways: a valid flag works, and an unknown or malformed one fails the container at startup with an actionable usage error (previously extra args were silently ignored).

Malformed values now fail loudly at startup

A typo'd configuration value used to be silently swallowed: a garbage KOPIUR_WORKER_THREADS fell back to the default, and a misspelled boolean (e.g. KOPIUR_STREAMING_LISTS=ture) silently meant "off". Every value is now validated at startup — an unparseable number, boolean, socket address, role kind, or pull policy stops the process with a message naming the variable, the accepted values, and the fix. Chart-rendered values are unaffected (the chart only emits valid ones); hand-set extraEnv / extraArgs values get the loud failure instead of a silent mis-configuration.

Pod security

Security contexts are now per-component. The root podSecurityContext / securityContext are the controller's; the webhook carries its own webhook.podSecurityContext / webhook.securityContext with the same restricted defaults, so relaxing the controller (e.g. runAsUser: 1000 + fsGroup to read a filesystem/NFS-backed repository for in-process kopia ops) never loosens the webhook.

  # No limits are set by default. Uncomment and tune to your own measured
  # ceiling if you want them (a memory limit below the startup burst OOM-loops
  # the controller — see the note above):
  # limits:
  #   memory: 1Gi
# -- Extra volumes on the controller pod. Use this to make a filesystem-backend
# repository reachable in-process (hostPath/NFS/PVC) so the controller can run
# short idempotent kopia ops directly. The e2e harness uses a hostPath here.
extraVolumes: []
# -- Extra volume mounts on the controller container (pairs with extraVolumes).
extraVolumeMounts: []
# -- Scheduling controls (fall back to global.* when left empty).
nodeSelector: {}
tolerations: []
affinity: {}
# -- Spread controller replicas across nodes/zones. Only meaningful with
# replicaCount > 1; pairs with podDisruptionBudget so a drain can't collapse
# both replicas onto one node and then evict them together.
topologySpreadConstraints: []
# -- Pod-level priority class.
priorityClassName: ""

Defaults for both: non-root uid/gid 65534 (nobody), runAsNonRoot, a RuntimeDefault seccomp profile, no privilege escalation, a read-only root filesystem, and all capabilities dropped (the images are distroless:nonroot). The webhook's own block:

    pullPolicy: IfNotPresent
  # -- failurePolicy for both webhook configurations: Fail (fail-closed,
  # recommended for a backup operator) or Ignore. Fail means a webhook outage
  # blocks kopiur CR writes — see podDisruptionBudget below for why HA matters.
  failurePolicy: Fail
  # -- timeoutSeconds for admission requests (1..30).
  timeoutSeconds: 10
  # -- The webhook's container port. Rendered into KOPIUR_WEBHOOK_ADDR as
  # "[::]:<port>" (dual-stack wildcard); the Service maps 443 -> this port.
  port: 8443
  # No limits by default (see the controller resources note). The webhook links
  # the same OpenTelemetry/OTLP stack but does no kopia work, so it stays light;
  # only the requests are set to reserve a fair share.
  resources:
    requests:
      cpu: 25m
      memory: 64Mi

This is the operator's security context, not the mover's

These podSecurityContext / securityContext blocks govern the controller and webhook pods. The UID/GID that a mover Job runs as — which has to match the ownership of the data being backed up so it can read it — is configured per-SnapshotPolicy / per-Restore, not here. See Permissions, UID & GID and Security context.

A ready-made observability overlay

The repo ships an overlay that flips the whole metrics + dashboard surface on at once. Pass it with helm -f:

# Example Helm VALUES overlay that turns on Kopiur's full observability surface.
# (This is a Helm values file — pass it with `helm -f` — not a Custom Resource
# like the manifests under deploy/examples/.)
#
#   helm upgrade --install kopiur deploy/helm/kopiur -n kopiur-system \
#     -f deploy/observability-values.yaml
#
# See docs/dev/observability.md for the full metric list and details.

# The controller's metrics Service is always created; the monitoring umbrella
# wires up the Prometheus-Operator objects and the Grafana dashboard.
monitoring:
  serviceMonitor:
    # Requires the Prometheus-Operator CRDs. Add labels here if your Prometheus
    # uses a serviceMonitorSelector.
    enabled: true
    # labels:
    #   release: kube-prometheus-stack
  prometheusRule:
    # Ships the kopiur alert rules (backup stale/failed, repo not ready, etc.).
    enabled: true
    # A SnapshotPolicy is "stale" if its last success is older than this (seconds).
    backupStaleAfterSeconds: 172800 # 48h
  # Ship the Grafana dashboard as a ConfigMap the Grafana sidecar auto-discovers.
  # Adjust `label`/`labelValue` to match your sidecar's watch label.
  dashboards:
    enabled: true
    label: grafana_dashboard
    labelValue: "1"

# Scrape the webhook's /metrics (served over its TLS port; self-signed by
# default). This stays under `webhook:` — it's an HTTPS scrape of the webhook's
# own port, distinct from the controller scrape in `monitoring:` above.
webhook:
  serviceMonitor:
    enabled: true
    insecureSkipVerify: true

# Optional: export OTLP traces + logs + a metrics push to a collector. Leave
# disabled to run Prometheus-pull only. Only gRPC is supported — point at the
# collector's gRPC port (4317).
observability:
  otlp:
    enabled: false
    endpoint: "http://otel-collector.observability.svc:4317"
    protocol: "grpc"
    # headers: "authorization=Bearer <token>"
    # strict: false              # true = fail-fast on telemetry misconfiguration
    # extraEnv:                  # e.g. tail-sampling / resource attributes
    #   - name: OTEL_TRACES_SAMPLER
    #     value: parentbased_traceidratio
    #   - name: OTEL_TRACES_SAMPLER_ARG
    #     value: "0.1"
helm upgrade --install kopiur oci://ghcr.io/home-operations/charts/kopiur -n kopiur-system \
  -f deploy/observability-values.yaml

The complete values.yaml

The whole annotated file, exactly as the chart ships it:

Full values.yaml (click to expand)
# Default values for the kopiur chart.
# This is a YAML-formatted file. Every key is documented inline.
# See deploy/helm/kopiur/README.md for install modes and worked examples.
#
# COMPONENT MODEL (read this first): the controller is the chart's primary
# component, so its knobs live at the ROOT (unprefixed) — `replicaCount`,
# `resources`, `nodeSelector`, `podSecurityContext`, `image`, and so on all
# configure the CONTROLLER. The two auxiliary components get their own named
# blocks: `webhook:` (a full Deployment surface) and `mover:` (the per-Job
# image only). So a root-level workload key applies to the controller ALONE —
# the webhook has its own under `webhook:`, and the mover, a controller-stamped
# Job, takes no workload/scheduling keys (only `mover.image`).

# -- Override the chart name used in resource names (defaults to .Chart.Name = "kopiur").
nameOverride: ""
# -- Override the full release-qualified name (defaults to "<release>-kopiur").
fullnameOverride: ""

# =============================================================================
# Global (chart-wide fallbacks + umbrella-injectable subset)
# =============================================================================
# kopiur has no subcharts, so `global` does not auto-propagate anywhere on its
# own. Its two jobs: (a) kopiur's own templates read `global.*` as a shared
# fallback for the controller (root) and the webhook, so you set a fleet-wide
# default once instead of repeating it per component; (b) if kopiur is ever
# wrapped in an umbrella chart, Helm merges the umbrella's `global` down and
# kopiur inherits these for free. Merge rule: list-valued globals
# (imagePullSecrets, tolerations, topologySpreadConstraints) are CONCATENATED
# with the component value; map-valued globals (nodeSelector, affinity) are used
# only when the component leaves its own value empty; commonLabels are added to
# every rendered object.
global:
  # -- Concatenated with the top-level imagePullSecrets; reaches controller,
  # webhook, and mover Jobs.
  imagePullSecrets: []
  # -- Scheduling defaults every kopiur pod inherits unless the component
  # (root = controller, webhook.*) sets its own.
  nodeSelector: {}
  tolerations: []
  affinity: {}
  topologySpreadConstraints: []
  # -- Labels stamped on every rendered object (fleet-wide labelling).
  commonLabels: {}

# =============================================================================
# Install scope
# =============================================================================
# -- "cluster" (default) or "namespaced".
#   cluster    — RBAC is a ClusterRole/ClusterRoleBinding. The operator manages
#     kopiur.home-operations.com resources cluster-wide AND reconciles
#     ClusterRepository. This is the default because a namespace-scoped Role
#     silently disables the ClusterRepository kind (a cluster-scoped resource is
#     out of a Role's reach), turning kopiur into a shared-backup-tier operator
#     that can't do shared backup tiers.
#   namespaced — RBAC is a namespace-scoped Role/RoleBinding: the operator
#     manages resources only in its own namespace and ClusterRepository is NOT
#     reconciled. Choose this as the explicit least-privilege opt-down for a
#     single-team install where the reduced blast radius is worth losing
#     cluster-scoped repositories.
installScope: cluster

# =============================================================================
# Controller image
# =============================================================================
# Three images ship with kopiur:
#   * controller — the operator (reconcilers); exposes /metrics. Configured here.
#   * webhook    — the axum admission server; see webhook.image below.
#   * mover      — the per-Job image (one Job per Backup/Restore/Maintenance
#                  run); see mover.image below.
# When `digest` is set it wins over `tag` (tag defaults to .Chart.AppVersion).
# Charts published to oci://ghcr.io/home-operations/charts ship all three
# digests pinned to the release images (the in-repo defaults stay empty and
# float on tags); on a published chart set digest: "" to make tag effective.
image:
  # -- Full controller image repository (registry + path).
  repository: ghcr.io/home-operations/kopiur-controller
  # -- Defaults to .Chart.AppVersion when empty.
  tag: ""
  # -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
  digest: ""
  # -- Image pull policy for the controller.
  pullPolicy: IfNotPresent

# -- Image pull secrets for the controller pod (concatenated with
# global.imagePullSecrets; also reaches the webhook pod and mover Jobs).
imagePullSecrets: []
#  - name: my-registry-creds

# =============================================================================
# Controller runtime behavior
# =============================================================================
# -- Number of controller replicas. >1 enables HA via leader election; only the
# elected leader reconciles, so deterministic jitter keeps schedules identical
# across replicas and across failover. Pair >1 with podDisruptionBudget and
# topologySpreadConstraints below to make it genuinely highly-available.
replicaCount: 1
# -- Enable leader election. Required when replicaCount > 1; harmless at 1.
leaderElection:
  enabled: true
# -- Tokio worker threads for the controller runtime. The controller is I/O-bound,
# so a small pool is ample; the runtime default sizes to the host core count
# (ignoring the cgroup CPU quota), over-allocating worker threads — each a stack
# plus a malloc arena — on large nodes, inflating RSS for no throughput gain.
# Raise only for a reconcile-heavy deployment.
workerThreads: 2
# -- Use the Kubernetes WatchList streaming-list API for the controller's
# cluster-wide watches, lowering peak memory during the initial resync by
# streaming pages instead of buffering them (the startup burst the resources
# note below warns about). On by default: WatchList is GA in Kubernetes 1.34
# (beta 1.32/1.33) and the chart's kubeVersion floor is 1.32. Set `false` if
# your apiserver has the WatchList feature gate disabled — the watches degrade
# to paged lists either way, but turning it off skips the feature probe.
streamingLists: true
# @schema
# type: integer
# minimum: 0
# @schema
# -- 0 = uncapped (default); set to bound concurrent snapshot-delete batch
# Jobs across repositories (mass-deletion protection). Batching itself is the
# primary protection — one Job per repository per accumulation window, not
# one per Snapshot — so this cap is an opt-in backstop for a
# resource-constrained cluster, not a load-bearing safety mechanism: a small
# cap risks head-of-line-blocking every OTHER repository's deletions behind
# one slow/failing one. It does NOT bound how many Snapshots one batch Job
# deletes, nor does it gate whether a deletion is allowed at all — that's
# deletionProtection.threshold on the Repository/ClusterRepository.
maxConcurrentDeleteJobs: 0
# @schema
# type: integer
# minimum: 0
# maximum: 65535
# @schema
# -- Per-controller cap on concurrently running reconciles (the operator runs
# 8 controllers, so the process-wide worst case is 8x this). Bounds API-server
# load and file descriptors during re-list storms and API-server outages —
# unbounded reconcile concurrency is what let an apiserver flap exhaust the
# controller's fd table (EMFILE) within seconds. The default 8 clears a
# few-hundred-object re-list in seconds while keeping slow reconciles (hooks,
# in-process kopia ops) from starving a controller. 0 = unbounded (the
# pre-fix behavior; not recommended).
reconcileConcurrency: 8
# -- Extra CLI args appended to the controller container.
extraArgs: []
# -- Extra environment variables for the controller container.
extraEnv: []

# =============================================================================
# Controller workload & scheduling  (controller only — webhook has its own)
# =============================================================================
# -- Resource requests for the controller pod.
# No limits by default. No CPU limit (CPU throttling on an operator only adds
# reconcile latency; the request reserves a fair share). No memory limit either:
# the controller's RSS can *burst* on startup/restart, not just steady state
# (~120Mi) — on (re)start it reconciles every existing resource at once, spawning
# concurrent in-process `kopia` subprocesses (whose RSS counts against this
# container's cgroup) to list/connect a repository that may hold many snapshots.
# A memory limit that doesn't cover that burst OOMKills the controller, which
# then crash-loops (OOM -> restart -> re-reconcile burst -> OOM). Set a limit
# only if you've measured your own ceiling. See crates/e2e/tests/lifecycle.rs.
resources:
  requests:
    cpu: 50m
    memory: 128Mi
  # No limits are set by default. Uncomment and tune to your own measured
  # ceiling if you want them (a memory limit below the startup burst OOM-loops
  # the controller — see the note above):
  # limits:
  #   memory: 1Gi
# -- Extra volumes on the controller pod. Use this to make a filesystem-backend
# repository reachable in-process (hostPath/NFS/PVC) so the controller can run
# short idempotent kopia ops directly. The e2e harness uses a hostPath here.
extraVolumes: []
# -- Extra volume mounts on the controller container (pairs with extraVolumes).
extraVolumeMounts: []
# -- Scheduling controls (fall back to global.* when left empty).
nodeSelector: {}
tolerations: []
affinity: {}
# -- Spread controller replicas across nodes/zones. Only meaningful with
# replicaCount > 1; pairs with podDisruptionBudget so a drain can't collapse
# both replicas onto one node and then evict them together.
topologySpreadConstraints: []
# -- Pod-level priority class.
priorityClassName: ""
# -- Extra pod labels / annotations.
podLabels: {}
podAnnotations: {}
# -- PodDisruptionBudget for the controller. Keeps a voluntary disruption (node
# drain, cluster upgrade) from taking the controller to zero replicas. Only
# useful with replicaCount > 1.
podDisruptionBudget:
  enabled: false
  minAvailable: 1

# =============================================================================
# Controller pod security (controller only)
# =============================================================================
# The controller may need a RELAXED context — e.g. runAsUser: 1000 + fsGroup to
# read a filesystem/NFS-backed repository for in-process kopia ops (which is why
# extraVolumes exists on it). The webhook is pure admission and keeps its own
# locked-down context under webhook.* so relaxing the controller never loosens
# the webhook. Non-root uid 65534 (nobody) by default — distroless:nonroot.
podSecurityContext:
  runAsNonRoot: true
  runAsUser: 65534
  runAsGroup: 65534
  fsGroup: 65534
  seccompProfile:
    type: RuntimeDefault
securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
      - ALL

# =============================================================================
# Controller port & probes
# =============================================================================
metrics:
  # -- The controller's single operational port. Rendered into KOPIUR_HTTP_ADDR
  # as "[::]:<port>" (dual-stack wildcard), co-hosting /metrics, /healthz and
  # /readyz. The metrics Service and the probes both target this port.
  port: 8081
# -- Liveness probe for the controller container. The whole probe is passed
# through with `toYaml`, so you can retune timings/thresholds or swap the scheme
# entirely; set to `{}` to drop the probe. `port: metrics` is the named
# container port (metrics.port above).
livenessProbe:
  httpGet:
    path: /healthz
    port: metrics
  initialDelaySeconds: 10
  periodSeconds: 15
# -- Readiness probe for the controller container (same passthrough as
# livenessProbe; set to `{}` to drop it).
readinessProbe:
  httpGet:
    path: /readyz
    port: metrics
  initialDelaySeconds: 5
  periodSeconds: 10

# =============================================================================
# ServiceAccount
# =============================================================================
serviceAccount:
  # -- Create the ServiceAccount. Disable to bring your own.
  create: true
  # -- Name to use; defaults to the chart fullname when empty.
  name: ""
  # -- Mount the ServiceAccount token into the controller/webhook pods.
  automount: true
  # -- Extra annotations (e.g. IRSA / Workload Identity role bindings).
  annotations: {}

# =============================================================================
# Feature permissions
# =============================================================================
# A few opt-in features need the operator to WRITE Secrets in the namespaces it
# manages, so each grants extra RBAC. All are OFF by default (least privilege):
# the chart does not hand out cluster-wide `secrets` write until you turn on the
# feature you use. The flag names match the CRD fields that trigger them.
#
# Symptom → fix: if you enable the feature in a CR but forget the flag here, the
# resource's `.status` shows an actionable condition like
#   "... (HTTP 403). ... Fix: set features.<name>.enabled: true ..."
# Set the matching flag below and the operator heals on the next reconcile.
# See docs/feature-permissions.md for the full mapping and security trade-offs.
features:
  credentialProjection:
    # -- Grant the operator `secrets` create+patch+delete so
    # `spec.credentialProjection` works: the operator copies a repository's
    # credential Secret(s) into each mover Job's namespace (the chief win is a shared
    # ClusterRepository whose Secret is pinned to one namespace) and cleans its
    # copies up again (legacy-copy sweep, reap-on-shrink, reap-on-disable).
    # SECURITY TRADE-OFF: `create` cannot be scoped to a Secret name, so the
    # operator can write a Secret in any namespace it manages.
    enabled: false
  kopiaUi:
    # -- Grant the operator `secrets` create+patch+delete so `spec.server` (the
    # kopia web-UI) works: it creates a generated-auth Secret, mirrors a
    # ClusterRepository's credentials into the server namespace, and deletes both on
    # teardown. SECURITY TRADE-OFF: grants unscoped create/delete on Secrets in any
    # namespace the operator manages.
    enabled: false

# =============================================================================
# Extra RBAC
# =============================================================================
rbac:
  # -- Render an OPT-IN ClusterRole "<release>-browse" carrying exactly what a
  # human needs to run the `kubectl kopiur ls/cat/download/browse` data-plane:
  # read snapshots/repositories, create/delete the session Job + ConfigMap, and
  # exec into the session pod. It deliberately grants NO access to Secrets —
  # the session pod loads the repository credentials itself, so a browsing user
  # never reads them (`--local` is the exception and additionally needs `get
  # secrets`, granted separately). The chart only renders the ClusterRole; bind
  # it to your users/groups with your own (Cluster)RoleBinding.
  browseRole: false

# =============================================================================
# Logging (stdout / `kubectl logs`, always on)
# =============================================================================
# Controls the fmt logging that every component writes to stdout — the stream
# `kubectl logs` shows. The controller passes RUST_LOG + KOPIUR_LOG_FORMAT
# through to mover Jobs, so a mover honors the same level and format. This is a
# chart-wide concern (all three components honor it), which is why it stays a
# named top-level block rather than folding under the controller. Env var names
# match crates/telemetry/src/env.rs. (For pushing logs/traces to a collector,
# see observability.otlp below — a different, optional path.)
logging:
  # -- Log level / filter directive (RUST_LOG style: error|warn|info|debug|trace;
  # per-target works too, e.g. "info,kopia=debug" to see kopia's own progress in
  # mover logs).
  level: info
  # -- Console format: "text" (human-readable, default) or "json" (one structured
  # object per line for Loki/ELK/Datadog). Unknown values degrade to text.
  format: text

# =============================================================================
# OpenTelemetry (OTLP) — optional push of traces + logs (and a metrics push)
# =============================================================================
# Separate from `logging` above (the always-on stdout stream): observability.otlp
# is the OPTIONAL push path. When enabled, the controller, webhook, and mover Jobs
# export OTLP to the configured collector (the controller passes the same OTEL_*
# env to movers). Metrics are ALSO always available via the Prometheus /metrics
# pull endpoint; OTLP just adds a push path plus traces and logs. Env var names
# match crates/telemetry/src/env.rs.
observability:
  otlp:
    # -- Enable OTLP export (sets OTEL_EXPORTER_OTLP_ENDPOINT on all components).
    enabled: false
    # -- Collector gRPC endpoint. Required when enabled. Only gRPC is compiled in.
    endpoint: "http://otel-collector.observability.svc:4317"
    # -- OTEL_EXPORTER_OTLP_PROTOCOL (only "grpc" is supported by this build).
    protocol: "grpc"
    # -- OTEL_EXPORTER_OTLP_HEADERS, e.g. "authorization=Bearer xyz". Empty to omit.
    headers: ""
    # -- Fail-fast on telemetry misconfiguration instead of degrading to fmt+pull.
    strict: false
    # -- Extra raw env (e.g. OTEL_TRACES_SAMPLER) added to every component.
    extraEnv: []

# =============================================================================
# Monitoring (Prometheus + Grafana)
# =============================================================================
# The controller-scrape ServiceMonitor, the alerting PrometheusRule, and the
# Grafana dashboard(s), grouped under one umbrella. NOTE: the webhook's own
# ServiceMonitor is NOT here — it lives at webhook.serviceMonitor because it is
# an HTTPS scrape of the webhook's TLS port, a materially different scrape config
# that moves with the webhook component, not this umbrella. The split is
# deliberate.
monitoring:
  serviceMonitor:
    # -- Create a Prometheus-Operator ServiceMonitor scraping the controller's
    # /metrics (plain HTTP). Requires the ServiceMonitor CRD to exist.
    enabled: false
    # -- Scrape interval.
    interval: 30s
    # -- Scrape timeout.
    scrapeTimeout: 10s
    # -- Extra labels (e.g. to match your Prometheus serviceMonitorSelector).
    labels: {}
    # -- Relabelings / metricRelabelings passed through verbatim.
    relabelings: []
    metricRelabelings: []
  prometheusRule:
    # -- Create a Prometheus-Operator PrometheusRule with kopiur alerts.
    enabled: false
    # -- Extra labels (e.g. to match your Prometheus ruleSelector).
    labels: {}
    # -- Age (seconds) after which a SnapshotPolicy's last success is "stale".
    backupStaleAfterSeconds: 172800 # 48h
  # -- Grafana dashboard(s) for the kopiur fleet. The same JSON lives in
  # deploy/dashboards/kopiur.json (the single source of truth, copied into the
  # chart by `cargo xtask gen-all`) for manual import. By default it ships as a
  # ConfigMap labeled for the Grafana sidecar to auto-discover; flip
  # grafanaOperator.enabled to render a grafana-operator GrafanaDashboard CR from
  # the very same JSON instead.
  dashboards:
    # -- Create the dashboard (a sidecar ConfigMap by default).
    enabled: false
    # -- Label the Grafana sidecar watches for (key: value). Adjust to your stack.
    label: grafana_dashboard
    labelValue: "1"
    # -- Annotation setting the Grafana folder for the sidecar ConfigMap (optional).
    folderAnnotation: ""
    folder: ""
    # -- Namespace for the dashboard object; defaults to the release namespace.
    namespace: ""
    # -- Extra annotations added to the dashboard object (ConfigMap or CR).
    annotations: {}
    # -- Extra labels added to the dashboard object (ConfigMap or CR).
    labels: {}
    grafanaOperator:
      # -- Render a grafana-operator GrafanaDashboard CR instead of the sidecar ConfigMap.
      enabled: false
      # -- Allow a Grafana in any namespace to import this GrafanaDashboard.
      allowCrossNamespaceImport: true
      # -- Folder to create the dashboard in (Grafana folder name).
      folder: ""
      # -- How often grafana-operator re-checks the dashboard for updates.
      resyncPeriod: "10m"
      # -- spec.instanceSelector.matchLabels — selects which Grafana instance(s) load this dashboard.
      matchLabels: {}

# =============================================================================
# Admission webhook  (its own Deployment + Service + configs)
# =============================================================================
# A separate Deployment + Service, not folded into the controller. Everything
# the webhook workload needs lives here (its own image, port, scheduling,
# security context, PDB, spread, scrape) — a root-level key never touches it.
webhook:
  # -- Deploy the webhook (Deployment + Service + Validating/Mutating configs).
  # When false, validation falls back to the controller's defensive checks only.
  enabled: true
  replicaCount: 1
  # Admission webhook image (same tag/digest rules as the controller `image`).
  image:
    # -- Full webhook image repository (registry + path).
    repository: ghcr.io/home-operations/kopiur-webhook
    # -- Defaults to .Chart.AppVersion when empty.
    tag: ""
    # -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
    digest: ""
    # -- Image pull policy for the webhook.
    pullPolicy: IfNotPresent
  # -- failurePolicy for both webhook configurations: Fail (fail-closed,
  # recommended for a backup operator) or Ignore. Fail means a webhook outage
  # blocks kopiur CR writes — see podDisruptionBudget below for why HA matters.
  failurePolicy: Fail
  # -- timeoutSeconds for admission requests (1..30).
  timeoutSeconds: 10
  # -- The webhook's container port. Rendered into KOPIUR_WEBHOOK_ADDR as
  # "[::]:<port>" (dual-stack wildcard); the Service maps 443 -> this port.
  port: 8443
  # No limits by default (see the controller resources note). The webhook links
  # the same OpenTelemetry/OTLP stack but does no kopia work, so it stays light;
  # only the requests are set to reserve a fair share.
  resources:
    requests:
      cpu: 25m
      memory: 64Mi
    # No limits are set by default. Uncomment and tune if you want them:
    # limits:
    #   memory: 512Mi
  # -- Scheduling controls (fall back to global.* when left empty).
  nodeSelector: {}
  tolerations: []
  affinity: {}
  # -- Spread webhook replicas across nodes/zones (pairs with podDisruptionBudget).
  topologySpreadConstraints: []
  priorityClassName: ""
  podLabels: {}
  podAnnotations: {}
  # -- PodDisruptionBudget for the webhook — the most important one to enable in
  # HA. With failurePolicy: Fail, a node drain that evicts the only webhook
  # replica blocks every kopiur CR write until it reschedules; a PDB with
  # replicaCount > 1 keeps one replica serving through the drain.
  podDisruptionBudget:
    enabled: false
    minAvailable: 1
  # -- Pod security context for the webhook pod. Kept at the most locked-down
  # posture (the webhook is pure admission and never touches a repository), so
  # relaxing the controller's context never loosens the webhook.
  podSecurityContext:
    runAsNonRoot: true
    runAsUser: 65534
    runAsGroup: 65534
    fsGroup: 65534
    seccompProfile:
      type: RuntimeDefault
  # -- Container security context for the webhook.
  securityContext:
    allowPrivilegeEscalation: false
    readOnlyRootFilesystem: true
    capabilities:
      drop:
        - ALL
  # -- Extra environment variables for the webhook container (list of
  # `{name, value}` / `{name, valueFrom}` entries), appended after the
  # operator-managed env. Mirrors the root `extraEnv`.
  extraEnv: []
  # -- Liveness probe for the webhook container. Passed through with `toYaml`
  # (retune timings/thresholds or swap the probe; `{}` drops it). The webhook
  # only serves HTTPS, so `scheme: HTTPS` on the named `https` port.
  livenessProbe:
    httpGet:
      path: /healthz
      port: https
      scheme: HTTPS
    initialDelaySeconds: 5
    periodSeconds: 15
  # -- Readiness probe for the webhook container (same passthrough as
  # livenessProbe; set to `{}` to drop it).
  readinessProbe:
    httpGet:
      path: /readyz
      port: https
      scheme: HTTPS
    initialDelaySeconds: 5
    periodSeconds: 10
  # The webhook serves /metrics on its TLS port (kopiur_webhook_admission_total).
  # This ServiceMonitor stays on the webhook (not under monitoring:) because it
  # is an HTTPS scrape of the webhook's own port — a different scrape config from
  # the controller's plain-HTTP one.
  serviceMonitor:
    # -- Create a ServiceMonitor scraping the webhook's /metrics over HTTPS.
    enabled: false
    interval: 30s
    scrapeTimeout: 10s
    labels: {}
    # -- The webhook serves a self-signed cert, so skip verification by default.
    insecureSkipVerify: true
  # TLS for the webhook server. The webhook ALWAYS serves TLS (k8s requires
  # HTTPS for admission). How the serving cert is provisioned is chosen by
  # tls.mode below.
  tls:
    # -- How the webhook serving certificate is provisioned and trusted. One of:
    #   self         — the operator mints its own CA + serving cert, writes the
    #                  Secret, and injects caBundle into the webhook
    #                  configurations itself. No cert-manager, no manual steps,
    #                  and the leaf is auto-rotated before expiry. (default)
    #   cert-manager — cert-manager issues the serving cert and its ca-injector
    #                  populates caBundle (requires cert-manager installed;
    #                  configure certManager.issuerRef below).
    #   manual       — you pre-create the tls.secretName Secret (kubernetes.io/tls)
    #                  and set webhook.caBundle (base64 PEM) yourself.
    mode: self
    # -- Name of the Secret holding tls.crt / tls.key (and, in self mode, ca.crt).
    # In self mode the operator creates and owns it; in cert-manager mode
    # cert-manager writes it; in manual mode YOU create it before install.
    secretName: kopiur-webhook-tls
  certManager:
    # -- Use an existing Issuer/ClusterIssuer instead of the self-signed Issuer
    # this chart creates. Only used when tls.mode is cert-manager. Leave name
    # empty to use the chart-managed self-signed Issuer.
    issuerRef:
      name: ""
      kind: Issuer # Issuer | ClusterIssuer
  # -- Base64-encoded PEM CA bundle injected into the webhook configurations.
  # Only used when tls.mode is manual; required there so the API server trusts
  # the serving cert. Ignored in self and cert-manager modes (caBundle is
  # populated by the operator or cert-manager's ca-injector respectively).
  caBundle: ""

# =============================================================================
# Mover (per-Job image)
# =============================================================================
# The mover has no Deployment — the controller stamps one Job per
# Backup/Restore/Maintenance run, reading this image reference. Its security
# context and scheduling are controller-stamped, not chart values, so only the
# image is configurable here.
mover:
  image:
    # -- Full mover image repository (registry + path).
    repository: ghcr.io/home-operations/kopiur-mover
    # -- Defaults to .Chart.AppVersion when empty.
    tag: ""
    # -- Pin the mover image by digest so a re-pulled tag can never change what
    # runs in a data-protection Job. STRONGLY RECOMMENDED in production.
    digest: ""
    # -- Pull policy used on the mover Job pods.
    pullPolicy: IfNotPresent

See also