Skip to content

Helm chart values

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

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

  • controller: the operator, meaning the reconcilers. It serves /metrics, /healthz, and /readyz.
  • webhook: a separate axum admission Deployment, both validating and mutating.
  • mover: not a Deployment. The controller stamps this image into every Snapshot, Restore and Maintenance Job it creates.

The controller is the chart’s primary component, so its knobs live at the root, with no prefix. replicaCount, resources, nodeSelector, podSecurityContext, image and so on all configure the controller. The two auxiliary components get their own named blocks: webhook: gets a full Deployment surface, and mover: gets 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.

# -- 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: ""

These are the 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.

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

  • controller: the root image block is image.repository, image.tag, image.digest, image.pullPolicy.
  • webhook: webhook.image.*, with its own webhook.image.pullPolicy.
  • mover: mover.image.*, with its own mover.image.pullPolicy.
# =============================================================================
# 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 (concatenated with global.imagePullSecrets) for the
# controller, webhook and console pods. Their NAMES are also passed to every
# mover pod, but a name resolves in the pod's own namespace and kopiur does not
# copy Secrets: create the Secret in each namespace movers run in — every
# namespace with a SnapshotPolicy/Snapshot/Restore (backup, restore, verify and
# browse-session pods run beside the workload), each Repository's namespace
# (bootstrap, delete, pin, replication, kopia server), the Maintenance CR's
# namespace, and the operator namespace (ClusterRepository bootstrap/delete).
imagePullSecrets: []

Each image takes a tag, which 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, using Always, IfNotPresent or Never. When it is unset, the controller infers IfNotPresent whenever an explicit mover image is configured, so a pinned image, for example one loaded locally, is never re-pulled. Otherwise it leaves the cluster default in charge.

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

# =============================================================================
# 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
# -- Under `installScope: namespaced`, also grant the operator cluster-scoped
# `get` on `namespaces` (and nothing else) via a small supplementary ClusterRole.
#
# ONLY needed for `stream` sources. A stream source execs a command inside a
# workload pod, which requires the operator to mint `pods/exec` for the mover —
# arbitrary code execution in every pod in that namespace, on a ServiceAccount
# that outlives the Job. Kopiur therefore refuses unless a cluster admin has
# annotated the namespace `kopiur.home-operations.com/stream-exec-movers=true`,
# and it FAILS CLOSED when it cannot read the Namespace to check (unlike the
# elevated-mover opt-in, which fails open — `pods/exec` is the larger grant, and
# a 403 on `namespaces get` cannot be distinguished from a deny an admin added on
# purpose).
#
# A namespaced install has no `namespaces` rule at all, so stream sources are
# refused permanently there. Set this to `true` to add just that one read, or use
# `installScope: cluster` (which already has it). Leave it `false` if you do not
# use stream sources: PVC and NFS sources need nothing here.
#
# Ignored under `installScope: cluster`.
rbacNamespaceReadForStreamSources: false
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.

rbacNamespaceReadForStreamSources is the one supplementary grant a namespaced install may need, and only if you use stream sources. A stream source execs a command inside a workload pod, so the operator has to mint pods/exec for the mover, and it refuses to do that unless a cluster admin has annotated the namespace. Checking that annotation needs get on namespaces, which is cluster-scoped and therefore absent from a Role entirely — so the check fails closed and stream sources are refused. Setting this to true adds a small supplementary ClusterRole granting get on namespaces and nothing else. It is ignored under installScope: cluster, which already has that read, and pvc and nfs sources never need it. See RBAC and Streamed command sources.

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

# =============================================================================
# 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 https://kopiur.home-operations.com/feature-permissions/ for the full mapping and security trade-offs.
features:
credentialProjection:
# -- Enable `spec.credentialProjection` (copy a repository's credential
# Secret(s) into each mover Job's namespace) by granting the operator `secrets`
# create+patch+delete. The chief win is a shared ClusterRepository whose Secret
# is pinned to one namespace; the operator also 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:
# -- Enable `spec.server` (the kopia web-UI) by granting the operator `secrets`
# create+patch+delete. The server needs a generated-auth Secret, a mirror of
# each of a ClusterRepository's credential Secrets in the server namespace, and
# deletion of all of them on teardown.
# SECURITY TRADE-OFF: grants unscoped create/delete on Secrets in any namespace
# the operator manages.
enabled: false
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
# =============================================================================
# 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: {}

Set serviceAccount.create: false to bring your own. The annotations map is where IRSA and 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 and webhook pods.

The controller’s knobs live at the root of the values file, with 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
# -- Leader-election protocol timings, in seconds. Leave unset to use the
# client-go defaults (lease 15 / renewDeadline 10 / renewPeriod 2 / retry 2).
# `renewDeadline` is a budget for a whole ROUND of renew attempts, not one
# attempt's timeout, and `renewPeriod + renewDeadline` must stay strictly
# under `leaseDuration` — the operator rejects any other combination at
# startup rather than risk two replicas both believing they lead. Widen these
# only if your control plane genuinely cannot answer a lease write inside the
# default window; prefer leaving `flowSchema` enabled instead.
timings: {}
# leaseDurationSeconds: 15
# renewDeadlineSeconds: 10
# renewPeriodSeconds: 2
# retryPeriodSeconds: 2
# -- API Priority and Fairness lane for the election Lease. Kubernetes' own
# `system-leader-election` FlowSchema covers only kube-system ServiceAccounts,
# so an operator in its own namespace has its lease renewals queued behind all
# other ServiceAccount traffic in `workload-low` — the congestion behind the
# controller restart storms in #319. This puts Kopiur's `leases` calls (and
# only those) into the built-in guaranteed `leader-election` priority level.
# Turn off if APF is disabled on the cluster or you cannot create
# cluster-scoped flowcontrol.apiserver.k8s.io objects.
flowSchema:
enabled: true
# -- Ordering against other FlowSchemas; lower wins. Must stay above the
# built-in `system-leader-election` (100) so kube's own controllers keep
# priority, and below `service-accounts` (9000) or this has no effect.
matchingPrecedence: 200
# -- 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 = no cluster-wide cap (default); set to bound concurrent snapshot-delete
# batch Jobs across ALL repositories. Each repository is already capped on its
# own by spec.concurrency.maxConcurrentDeleteJobs (default 1: one batch at a
# time, each deleting hundreds of Snapshots in one kopia call), so this 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
# @schema
# -- 0 = uncapped (default); set to bound how many POOLED mover Jobs — backup
# snapshots, restores, and the source side of either replication — may run at
# once across ALL repositories. This is the cluster-operator BACKSTOP beneath
# each repository's own spec.concurrency.maxConcurrentJobs, which stays the
# primary knob: a run must satisfy both, and whichever cap it meets first
# holds it. Set this when the node pool cannot host N movers no matter which
# repositories they belong to; set the per-repository field when one BACKEND
# is the thing that must not be saturated. Restores are ALWAYS admitted and
# never queued (a recovery in progress must not wait behind routine backups),
# though a running restore still occupies a slot and so displaces backups.
# Maintenance, verification, pin and snapshot-delete Jobs are outside the pool
# entirely. Leaving this at 0 costs nothing: with no cap set anywhere the
# operator performs no extra API calls at all.
maxConcurrentJobs: 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

This is the operator itself. The settings worth knowing:

  • replicaCount + leaderElection: run more than one replica for high availability. With leaderElection.enabled, the default, the replicas elect a leader through a coordination.k8s.io/v1 Lease in the release namespace, named after the release. Only the Lease holder runs reconcilers. Standby replicas stay Ready, because probes and /metrics are served by every replica, and they take over within about 15s, the lease duration, if the leader dies. On a graceful shutdown the takeover is immediate, because 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: failing fast beats a split-brain double-reconcile. If the leases RBAC is missing, for example 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 high availability never doubles or skews a scheduled backup.
  • streamingLists: use the Kubernetes WatchList streaming-list API for the controller’s cluster-wide watches. It lowers peak memory during the initial resync by streaming pages instead of buffering them, which is the startup burst the memory note below warns about. Default true: WatchList is beta in Kubernetes 1.32 and 1.33 and generally available 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 it only for a reconcile-heavy deployment.
  • reconcileConcurrency: per-controller cap on concurrently running reconciles, default 8. The operator runs 8 controllers, so that is at most 64 process-wide. This is the one cap on this page that is bounded by default, because unbounded reconcile concurrency is what let an apiserver flap exhaust the controller’s file descriptors within seconds. 0 means unbounded, which is not recommended.
  • maxConcurrentJobs: cluster-wide cap on pooled mover Jobs, counted across all repositories, default 0 which means uncapped. Pooled Jobs are backup snapshots, restores, and the source side of either replication. This is the cluster-operator’s backstop; the primary knob is each repository’s own spec.concurrency.maxConcurrentJobs. A run must satisfy both, and whichever cap it meets first parks it. Reach for this one when the node pool cannot host N movers regardless of which repositories they belong to. Reach for the per-repository field when one backend is the thing that must not be saturated. Restores are always admitted and never queued, though a running restore still occupies a slot. Maintenance, verification, pin and snapshot-delete Jobs are outside the pool entirely. Leaving it at 0 costs nothing: with no cap set anywhere the operator performs no extra API calls at all.
  • maxConcurrentDeleteJobs: cluster-wide cap on concurrent snapdel-* batch-delete Jobs, default 0 which means no cluster-wide cap. Each repository is already limited on its own by spec.concurrency.maxConcurrentDeleteJobs (default one batch at a time). This is a separate pool from maxConcurrentJobs, because a deletion reduces repository load, and queuing it behind backups would grow the backlog it exists to drain. It does not gate whether a deletion is allowed; that is deletionProtection.threshold on the repository.
  • extraVolumes / extraVolumeMounts: the way to make a filesystem backend reachable in-process, through hostPath, NFS or a 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 high availability genuine. The PodDisruptionBudget keeps a voluntary disruption, such as a node drain or cluster upgrade, from taking the controller to zero, and the spread constraints keep both replicas off the same node or zone. Both fall back to their global.* counterparts when left empty.
# =============================================================================
# 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

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.

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

The webhook is a separate Deployment plus Service, and the Service maps 443 → 8443.

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

# =============================================================================
# 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
  • 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: failing 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 PodDisruptionBudget to enable in a high-availability setup.
  • webhook.serviceMonitor: the webhook serves /metrics on its TLS port, and scraping it needs insecureSkipVerify, because it serves a self-signed cert by default. This stays under webhook: rather than monitoring: because it’s an HTTPS scrape of the webhook’s own port.
# 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: ""

The webhook always serves TLS, because 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. For a full walkthrough with --set commands for each mode, see Installation → Webhook TLS.

# =============================================================================
# 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: {}

All metrics are under the kopiur_ namespace and served through a Prometheus pull endpoint on the controller’s port, metrics.port. The controller’s metrics Service is always created, because that listener co-hosts /metrics with /healthz and /readyz, so there’s nothing to disable. The monitoring: block additionally connects the Prometheus Operator and Grafana:

  • monitoring.serviceMonitor.enabled: create a ServiceMonitor scraping the controller’s /metrics over plain HTTP. It 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, which is the namespace of the CR the metric describes, instead of having it overwritten by the controller’s namespace. Prometheus would otherwise move the CR’s namespace 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, which is 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, sourced from deploy/dashboards/kopiur.json. Flip monitoring.dashboards.grafanaOperator.enabled to render a grafana-operator GrafanaDashboard CR from the very same JSON instead.

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

# =============================================================================
# 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: []

This is off by default. Metrics are always available through the /metrics pull endpoint. Turning on OpenTelemetry Protocol (OTLP) adds a push path plus traces and logs. When enabled, the controller, webhook, and mover Jobs all export to the configured collector, because 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 plus 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 (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

This controls the stdout logging, what kubectl logs shows, that every component writes. The controller passes RUST_LOG and KOPIUR_LOG_FORMAT through to mover Jobs, so a mover honors the same level and format.

  • level: RUST_LOG-style, default info. Per-target works too: "info,kopia=debug" surfaces kopia’s own progress in mover logs.
  • format: text, which is human-readable and the default, or json, one structured object per line for Loki, ELK or Datadog.

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

Terminal window
$ 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, for local development or 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.

Security contexts are now per-component. The root podSecurityContext and securityContext are the controller’s. The webhook carries its own webhook.podSecurityContext and webhook.securityContext with the same restricted defaults, so relaxing the controller never loosens the webhook. You might relax the controller with, say, runAsUser: 1000 plus an fsGroup to read a filesystem or NFS-backed repository for in-process kopia ops.

# =============================================================================
# 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

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

# -- 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

The optional in-cluster web console, off by default. ui.enabled: true needs installScope: cluster and an identity source, and in header mode a proxy shared secret — the chart refuses to render otherwise, naming the value to set.

Value Default What it does
ui.enabled false Deploy the console.
ui.auth.userHeader / groupsHeader / emailHeader "" Headers your proxy sets. Setting userHeader selects header mode. emailHeader is display only — never impersonated.
ui.auth.groupsFormat delimited How the groups header is encoded: delimited (oauth2-proxy), base64Json (Envoy Gateway claimToHeaders), or json. See Step 1.
ui.auth.groupsSeparator , What the groups header is split on, when groupsFormat is delimited.
ui.auth.proxySecret.existingSecret / .key "" / token Secret holding the shared token the proxy sends as X-Kopiur-Proxy-Token. Required in header mode.
ui.auth.acknowledgeNoProxySecret false Deploy header mode without that token. Only when a mesh or NetworkPolicy genuinely admits nothing but your proxy.
ui.auth.allowedGroups [] Restricts which groups are forwarded; never grants access. Enforced twice — by the console, and as resourceNames on its impersonate rule.
ui.auth.anonymous.* disabled Run every request as one fixed identity. fallback: true additionally serves header-less requests that way.
ui.rbac.userRoles true Render kopiur-ui-viewer/-editor/-user and the namespaced -doctor Role.
ui.rbac.bindings [] Grants access: a list of {role, groups, users} entries, where role is user, viewer or editor — one ClusterRoleBinding each. Empty binds nobody. See Step 3.
ui.rbac.browseRole false Render kopiur-ui-browse. Read what it grants first.
ui.rbac.execPolicy.* disabled A ValidatingAdmissionPolicy narrowing browse pods/exec to kopiur-browse-* pods running kopia.
ui.networkPolicy.enabled + proxySelector false Admit the app port only from your proxy, the ops port only from monitoring.
ui.cache.enabled true Serve reads from watch-fed stores, filtered per user with SubjectAccessReviews. Off means every read is an impersonated LIST, and removes those grants from the console’s ClusterRole.
ui.session.* ttl 15m Browse session pod lifetime, readiness budget, and the exec/start concurrency caps.
ui.download.maxBytes / chunkTimeout 1 GiB / 60s Largest streamable file, and how long a transfer may make no progress.
ui.limits.* see values Manifest buffer cap, snapshot list cap, client and SAR cache sizes and TTLs.
ui.service.type ClusterIP Keep it. The chart renders no Ingress; point your proxy at this Service from inside the cluster.

The console is documented in full — trust model, roles, sessions, caps, troubleshooting by problem type — on Web console (kopiur-ui).

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

deploy/observability-values.yaml
# 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 https://kopiur.home-operations.com/dev/observability/ 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"
Terminal window
helm upgrade --install kopiur oci://ghcr.io/home-operations/charts/kopiur -n kopiur-system \
-f deploy/observability-values.yaml

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

Full values.yaml (click to expand)
deploy/helm/kopiur/values.yaml
# 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`).
# region naming
# -- 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: ""
# endregion naming
# =============================================================================
# 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. Set on the controller,
# webhook and console pods, and the NAMES are passed to every mover pod
# (backup/restore/maintenance Jobs, kopia server, browse sessions). A name
# resolves in the pod's own namespace and kopiur never copies Secrets, so the
# Secret must exist in each namespace movers run in (see imagePullSecrets).
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: {}
# region install-scope
# =============================================================================
# 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
# -- Under `installScope: namespaced`, also grant the operator cluster-scoped
# `get` on `namespaces` (and nothing else) via a small supplementary ClusterRole.
#
# ONLY needed for `stream` sources. A stream source execs a command inside a
# workload pod, which requires the operator to mint `pods/exec` for the mover —
# arbitrary code execution in every pod in that namespace, on a ServiceAccount
# that outlives the Job. Kopiur therefore refuses unless a cluster admin has
# annotated the namespace `kopiur.home-operations.com/stream-exec-movers=true`,
# and it FAILS CLOSED when it cannot read the Namespace to check (unlike the
# elevated-mover opt-in, which fails open — `pods/exec` is the larger grant, and
# a 403 on `namespaces get` cannot be distinguished from a deny an admin added on
# purpose).
#
# A namespaced install has no `namespaces` rule at all, so stream sources are
# refused permanently there. Set this to `true` to add just that one read, or use
# `installScope: cluster` (which already has it). Leave it `false` if you do not
# use stream sources: PVC and NFS sources need nothing here.
#
# Ignored under `installScope: cluster`.
rbacNamespaceReadForStreamSources: false
# endregion install-scope
# region controller-image
# =============================================================================
# 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 (concatenated with global.imagePullSecrets) for the
# controller, webhook and console pods. Their NAMES are also passed to every
# mover pod, but a name resolves in the pod's own namespace and kopiur does not
# copy Secrets: create the Secret in each namespace movers run in — every
# namespace with a SnapshotPolicy/Snapshot/Restore (backup, restore, verify and
# browse-session pods run beside the workload), each Repository's namespace
# (bootstrap, delete, pin, replication, kopia server), the Maintenance CR's
# namespace, and the operator namespace (ClusterRepository bootstrap/delete).
imagePullSecrets: []
# endregion controller-image
# - name: my-registry-creds
# region controller-runtime
# =============================================================================
# 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
# -- Leader-election protocol timings, in seconds. Leave unset to use the
# client-go defaults (lease 15 / renewDeadline 10 / renewPeriod 2 / retry 2).
# `renewDeadline` is a budget for a whole ROUND of renew attempts, not one
# attempt's timeout, and `renewPeriod + renewDeadline` must stay strictly
# under `leaseDuration` — the operator rejects any other combination at
# startup rather than risk two replicas both believing they lead. Widen these
# only if your control plane genuinely cannot answer a lease write inside the
# default window; prefer leaving `flowSchema` enabled instead.
timings: {}
# leaseDurationSeconds: 15
# renewDeadlineSeconds: 10
# renewPeriodSeconds: 2
# retryPeriodSeconds: 2
# -- API Priority and Fairness lane for the election Lease. Kubernetes' own
# `system-leader-election` FlowSchema covers only kube-system ServiceAccounts,
# so an operator in its own namespace has its lease renewals queued behind all
# other ServiceAccount traffic in `workload-low` — the congestion behind the
# controller restart storms in #319. This puts Kopiur's `leases` calls (and
# only those) into the built-in guaranteed `leader-election` priority level.
# Turn off if APF is disabled on the cluster or you cannot create
# cluster-scoped flowcontrol.apiserver.k8s.io objects.
flowSchema:
enabled: true
# -- Ordering against other FlowSchemas; lower wins. Must stay above the
# built-in `system-leader-election` (100) so kube's own controllers keep
# priority, and below `service-accounts` (9000) or this has no effect.
matchingPrecedence: 200
# -- 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 = no cluster-wide cap (default); set to bound concurrent snapshot-delete
# batch Jobs across ALL repositories. Each repository is already capped on its
# own by spec.concurrency.maxConcurrentDeleteJobs (default 1: one batch at a
# time, each deleting hundreds of Snapshots in one kopia call), so this 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
# @schema
# -- 0 = uncapped (default); set to bound how many POOLED mover Jobs — backup
# snapshots, restores, and the source side of either replication — may run at
# once across ALL repositories. This is the cluster-operator BACKSTOP beneath
# each repository's own spec.concurrency.maxConcurrentJobs, which stays the
# primary knob: a run must satisfy both, and whichever cap it meets first
# holds it. Set this when the node pool cannot host N movers no matter which
# repositories they belong to; set the per-repository field when one BACKEND
# is the thing that must not be saturated. Restores are ALWAYS admitted and
# never queued (a recovery in progress must not wait behind routine backups),
# though a running restore still occupies a slot and so displaces backups.
# Maintenance, verification, pin and snapshot-delete Jobs are outside the pool
# entirely. Leaving this at 0 costs nothing: with no cap set anywhere the
# operator performs no extra API calls at all.
maxConcurrentJobs: 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
# endregion controller-runtime
# region controller-security
# =============================================================================
# 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
# endregion controller-security
# region controller-probes
# =============================================================================
# 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
# endregion controller-probes
# region service-account
# =============================================================================
# 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: {}
# endregion service-account
# region feature-permissions
# =============================================================================
# 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 https://kopiur.home-operations.com/feature-permissions/ for the full mapping and security trade-offs.
features:
credentialProjection:
# -- Enable `spec.credentialProjection` (copy a repository's credential
# Secret(s) into each mover Job's namespace) by granting the operator `secrets`
# create+patch+delete. The chief win is a shared ClusterRepository whose Secret
# is pinned to one namespace; the operator also 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:
# -- Enable `spec.server` (the kopia web-UI) by granting the operator `secrets`
# create+patch+delete. The server needs a generated-auth Secret, a mirror of
# each of a ClusterRepository's credential Secrets in the server namespace, and
# deletion of all of them on teardown.
# SECURITY TRADE-OFF: grants unscoped create/delete on Secrets in any namespace
# the operator manages.
enabled: false
# endregion feature-permissions
# =============================================================================
# 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
# region logging
# =============================================================================
# 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
# endregion logging
# region opentelemetry
# =============================================================================
# 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: []
# endregion opentelemetry
# region monitoring
# =============================================================================
# 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: {}
# endregion monitoring
# region webhook
# =============================================================================
# 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
# endregion webhook
# region webhook-security
# -- 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
# endregion webhook-security
# -- 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
# region webhook-tls
# 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: ""
# endregion webhook-tls
# =============================================================================
# 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
# =============================================================================
# Web UI (kopiur-ui)
# =============================================================================
# An optional in-cluster web console: a Deployment + ClusterIP Service, off by
# default. It has no login page of its own. You put an authenticating proxy
# (oauth2-proxy, Authelia, Pomerium, ...) in front of it; the proxy asserts who
# the caller is in a header, and kopiur-ui IMPERSONATES that user on every
# apiserver call. Kubernetes RBAC — not the console — then decides what each
# person may see and do, which is why the console's own ServiceAccount can do
# almost nothing except impersonate.
#
# Two consequences worth reading before you enable this:
#
# 1. Identity headers are only trustworthy if nothing else can set them, and in
# a cluster anything that can reach the Service (including the apiserver's
# services/proxy) can send the same headers. So header mode requires a
# shared secret the proxy proves it knows (auth.proxySecret), or an explicit
# acknowledgement that something else closes that hole. The chart REFUSES to
# render otherwise — see auth.acknowledgeNoProxySecret.
# 2. **The chart never renders an Ingress.** Same posture as `spec.server`:
# kopiur does not own your edge. Point your proxy at the Service, and put
# your own Ingress/HTTPRoute in front of the PROXY, never in front of this.
ui:
# -- Deploy the web console. Requires installScope: cluster — impersonation
# and the console's cluster-wide reads cannot be expressed in a namespaced
# Role, so the chart refuses a namespaced install rather than ship a console
# that 403s on its first request.
enabled: false
# -- Number of console replicas. Stateless: browse sessions live in the
# cluster as Jobs, not in the process, so any replica can serve any request.
replicaCount: 1
image:
# -- Full web UI image repository (registry + path).
repository: ghcr.io/home-operations/kopiur-ui
# -- Defaults to .Chart.AppVersion when empty.
tag: ""
# -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
digest: ""
# -- Image pull policy for the console.
pullPolicy: IfNotPresent
# -- Container port serving the SPA and /api. Rendered into KOPIUR_UI_ADDR as
# "[::]:<port>" (dual-stack wildcard).
port: 8090
# -- Container port serving /metrics, /healthz and /readyz. A SECOND listener
# on purpose: probes and Prometheus must stay reachable from the monitoring
# namespace while the app port is restricted to the proxy, and /metrics must
# never sit behind the identity middleware.
opsPort: 8091
service:
# -- Service type. Keep ClusterIP: this Service is for your proxy to reach,
# not for the internet — a LoadBalancer or NodePort here publishes a console
# that trusts identity headers to whoever can send them.
#
# A non-ClusterIP type is allowed when the proxy really does live outside the
# cluster, but ONLY alongside auth.proxySecret: publishing the console AND
# setting auth.acknowledgeNoProxySecret is refused, because the pair is an
# unauthenticated console on the network.
type: ClusterIP
# -- Extra annotations on the console Service.
annotations: {}
# -- Extra labels on the console Service.
labels: {}
serviceAccount:
# -- Create the console's own ServiceAccount. It is deliberately NOT the
# operator's: the operator reads Secrets, and the console must never be able
# to (it impersonates instead, so every read is a user's read).
create: true
# -- Name to use; defaults to "<fullname>-ui" when empty.
name: ""
# -- Mount the ServiceAccount token into the console pod. Required — the
# token IS how the console authenticates to impersonate.
automount: true
# -- Extra annotations (e.g. IRSA / Workload Identity role bindings).
annotations: {}
# Identity: how the console learns who is calling. Exactly one of header mode
# (auth.userHeader) or anonymous-only mode (auth.anonymous) must be
# configured, or the chart refuses to render — a console with no identity
# source would run every request as its own ServiceAccount, which can
# impersonate anyone.
#
# Three separate jobs, three separate settings — the usual source of confusion:
# 1. Read the groups: groupsHeader + groupsFormat (+ groupsSeparator)
# 2. Restrict the groups: allowedGroups (optional; it never grants anything)
# 3. Grant access: ui.rbac.bindings, or your own ClusterRoleBinding
# Walkthrough with per-proxy recipes: https://kopiur.home-operations.com/ui/#identity-and-access
auth:
# -- Header the proxy puts the authenticated username in (e.g.
# X-Forwarded-User for oauth2-proxy, or whatever name you gave the `sub` /
# `preferred_username` claim in Envoy Gateway's claimToHeaders). Setting it
# selects header mode; a request without it gets a 401 unless
# auth.anonymous.fallback is on. Header names are case-insensitive.
userHeader: ""
# -- Header carrying the caller's groups (e.g. X-Forwarded-Groups). Optional
# — an identity with no groups is valid, it just matches fewer bindings.
# Check what arrived: the user menu at the bottom of the console's sidebar
# lists the groups the console read.
groupsHeader: ""
# @schema
# enum: [delimited, json, base64Json]
# @schema
# -- How the groups header is encoded. `delimited`: a plain list split on
# groupsSeparator, e.g. `admins,dev` (oauth2-proxy, most header-auth
# proxies). `json`: a JSON array, e.g. `["admins","dev"]`.
# `base64Json`: a base64-encoded JSON array, e.g. `WyJhZG1pbnMiXQ==` — what
# Envoy Gateway's SecurityPolicy `claimToHeaders` sends for a list claim
# like `groups`, and Envoy cannot be told otherwise. Wrong format symptom:
# the user menu shows one long unreadable group (and the console logs a
# warning naming this setting), or every request fails with
# `invalid-header-value`.
groupsFormat: delimited
# -- Separator the groups header uses when groupsFormat is `delimited`
# (ignored otherwise). oauth2-proxy emits ",". Surrounding spaces are
# trimmed; spaces inside a name (`Domain Users`) are kept.
groupsSeparator: ","
# -- Header carrying the caller's email. Display only: never impersonated,
# never used for authorization.
emailHeader: ""
# -- `userextras` keys the console may impersonate, as a list. Each one
# becomes its OWN `userextras/<key>` rule in the console's ClusterRole —
# Kubernetes RBAC has no wildcard there, so a `userextras/*` would authorize
# nothing and fail at the first request instead of at install.
impersonateExtraKeys: []
# -- Optional allow-list of groups the console may impersonate. It only
# restricts, it never grants: listing `Admins` here gives Admins no access
# — bind a role for that (ui.rbac.bindings). Empty (the
# default) means "whatever groups the proxy asserts". Set, every other
# group the proxy sends is dropped before it reaches Kubernetes (the user
# menu names them behind a collapsed "N groups not forwarded" line), and
# the list is enforced twice:
# the console filters inbound groups, and the same list is pinned as
# `resourceNames` on the impersonate rule so the apiserver enforces it even
# if the console did not. Use it when your IdP sends many groups and only a
# few matter. Names are exact and case-sensitive, and may not contain a
# comma.
allowedGroups: []
proxySecret:
# -- Name of an existing Secret holding the shared token the proxy must
# send in `X-Kopiur-Proxy-Token`. YOU create it (and configure the proxy to
# send it); the chart only mounts it. This is what makes the identity
# headers trustworthy, so without it — and without the acknowledgement
# below — the chart refuses to render.
existingSecret: ""
# -- Key within that Secret holding the token.
key: token
# -- Deploy header mode WITHOUT a proxy shared secret. Mirrors the
# repositories' `acknowledgeInsecure`: the unsafe posture stays reachable,
# but only for someone who wrote down that they meant it — and it is
# greppable in your values afterwards. Only set this when something else
# (a service mesh with mTLS, a NetworkPolicy) genuinely prevents anything
# but your proxy from reaching the Service.
acknowledgeNoProxySecret: false
anonymous:
# -- Run every request as one fixed identity instead of reading a header.
# Sensible only where an outer layer has already authenticated everyone
# identically, or for a read-only demo. The console's impersonate rules
# are then pinned by NAME to this identity, so it cannot become anyone
# else even if the header parsing were fooled.
enabled: false
# -- The username to impersonate. Required when anonymous.enabled. May not
# be a `system:` principal (`system:masters` least of all) — the console
# refuses to start on one, and so does this chart's RBAC.
user: ""
# -- Groups paired with that user.
groups: []
# -- In HEADER mode, serve a request whose identity header is absent as the
# anonymous identity instead of rejecting it with 401. Off by default and
# never implicit: a silent downgrade from "the proxy said who you are" to
# "everyone is this fixed user" is exactly the failure this flag exists to
# make deliberate. Requires anonymous.enabled and anonymous.user.
fallback: false
cache:
# -- Serve reads from watch-fed stores under the console's own
# ServiceAccount, filtered per user with SubjectAccessReviews, instead of one
# impersonated LIST per request. This is the ONLY reason the console reads a
# Kopiur object as itself, so turning it off REMOVES those grants from its
# ClusterRole entirely. Leave it on unless you want every read impersonated.
enabled: true
session:
# -- How long an idle browse-session pod lives before it is reaped.
ttl: 15m
# -- How long `POST …/session` waits for the session pod to become ready.
# Generous: the pod may have to pull the mover image.
readyTimeout: 300s
# -- Cap on concurrent session-pod CREATIONS.
maxStarts: 4
# -- Cap on concurrent pods/exec calls ONE identity may have in flight.
maxExecPerIdentity: 4
# -- Process-wide cap on concurrent pods/exec calls.
maxExecGlobal: 64
download:
# -- Largest single file the console will stream, in bytes (default 1 GiB).
# Checked before streaming starts, so an oversized request is a 413 rather
# than a half-gigabyte of wasted egress.
maxBytes: 1073741824
# -- How long a download may make NO progress before it is abandoned. Not a
# total-transfer budget (a real restore outlives any fixed deadline) — it
# bounds the silence, so one stalled transfer cannot hold an exec slot and a
# kopia process forever.
chunkTimeout: 60s
limits:
# -- Largest kopia JSON manifest the console will buffer from an exec, in
# bytes (default 64 MiB). A directory with a million entries must fail as a
# 422, not as an OOM kill.
maxManifestBytes: 67108864
# -- Largest `limit` the snapshot list endpoint accepts.
snapshotListCap: 5000
# -- How many per-identity impersonating clients to keep. Each holds a
# connection pool, so this is a socket and memory bound.
clientCacheSize: 256
# -- How long an idle cached client survives.
clientCacheTtl: 10m
# -- How long a SubjectAccessReview answer is reused. Short by design: a
# revoked RoleBinding must stop hiding behind the cache quickly.
sarTtl: 60s
# -- How many SubjectAccessReview answers to retain.
sarCacheSize: 4096
rbac:
# -- Render the human roles: `kopiur-ui-viewer` (read-only) and
# `kopiur-ui-editor` (the console's action buttons), which aggregate into
# `kopiur-ui-user`, plus the namespaced `kopiur-ui-doctor` Role. Bind
# `kopiur-ui-user` to your people — either list them in `ui.rbac.bindings`
# below or write your own ClusterRoleBinding. The chart binds nobody on its
# own, because who may operate your backups is not a chart decision.
#
# These render only while `ui.enabled` is also true, so an install that does
# not run the console gains no ClusterRoles for a component it does not have.
# The consequence: setting `ui.enabled: false` DELETES these roles, and any
# (Cluster)RoleBinding you made to them is left with a dangling roleRef and
# grants nothing until you re-enable the console (bindings survive, so access
# returns by itself — nothing needs re-creating). That is only ever true
# while there is no console to use, which is why the roles stay gated rather
# than appearing in every install that has never heard of the UI.
userRoles: true
# -- Opt-in ClusterRoleBindings from your people to the human roles. Empty by
# default: nobody gets access until you say so. Each entry binds ONE role
# (`role: user|viewer|editor`) to any mix of `groups` and `users`, written
# exactly as your proxy sends them (case-sensitive; spaces allowed). E.g.
# `[{role: user, groups: [Admins]}, {role: viewer, groups: ["Domain Users"], users: [alice]}]`.
# `user` = viewer + editor (operate backups); `viewer` = read-only;
# `editor` = the action buttons alone. Requires userRoles. `browse` is
# deliberately not offered: it grants repository credentials, so bind
# kopiur-ui-browse yourself, per namespace (see browseRole). An optional
# `name` suffixes the ClusterRoleBinding's name (default: the entry index);
# two entries for the same role must end up with different names.
# If you set allowedGroups, every group named here must be in it too, or
# the console drops that group before Kubernetes can match the binding.
bindings: []
# -- Render `kopiur-ui-browse`, the role behind the in-snapshot file browser.
#
# READ THIS BEFORE ENABLING. Browsing runs a session pod and `pods/exec`s
# kopia into it, and that pod loads the repository credentials from its own
# environment — so anyone who can exec in that namespace can print them with
# `env`. `pods/exec create` cannot be narrowed to one pod by RBAC. Granting
# browse in a namespace therefore GRANTS THAT NAMESPACE'S REPOSITORY
# CREDENTIALS. It is deliberately NOT aggregated into `kopiur-ui-user`: bind
# it on purpose, ideally with a namespaced RoleBinding, and see execPolicy
# below for the enforcement RBAC cannot express.
browseRole: false
execPolicy:
# -- Render a ValidatingAdmissionPolicy that narrows browse `pods/exec` to
# `kopiur-browse-*` pods running `/usr/local/bin/kopia`, turning the
# console's closed command set into one the APISERVER enforces. This is the
# only real mitigation for what browseRole grants; it does NOT stop someone
# from exec'ing kopia commands, only from exec'ing `env` or a shell.
enabled: false
# -- Subjects the policy applies to — the same users/groups you bound
# `kopiur-ui-browse` to, as `{kind: User|Group|ServiceAccount, name, namespace}`.
# Required when enabled: a policy matching nobody enforces nothing, so the
# chart refuses to render one.
subjects: []
# -- Extra pod-name prefixes the policy also accepts, for a non-default
# session naming scheme. The built-in `kopiur-browse-` is always allowed.
extraPodPrefixes: []
networkPolicy:
# -- Render a NetworkPolicy admitting the app port only from your proxy and
# the ops port only from your monitoring stack. STRONGLY RECOMMENDED in
# header mode: it is what makes "only the proxy can set identity headers"
# true at the network layer rather than only on paper.
enabled: false
# -- Ingress peer allowed to reach the app port, as a NetworkPolicy peer
# (`namespaceSelector` and/or `podSelector`). Required when enabled — a
# policy with no peer would admit nothing, or (written loosely) everything.
proxySelector: {}
# -- Ingress peer allowed to reach the ops port (/metrics, probes). Leave
# empty to admit the ops port from anywhere in the cluster, which is the
# usual posture for a metrics endpoint that exposes no user data.
monitoringSelector: {}
tls:
# -- Serve the app port over HTTPS from an existing kubernetes.io/tls Secret.
# Usually unnecessary: the proxy terminates TLS and this hop is in-cluster.
existingSecret: ""
# No limits by default (see the controller resources note). The console is
# I/O-bound — it fans out to the apiserver and streams — so requests reserve a
# fair share and limits are left to you.
resources:
requests:
cpu: 25m
memory: 64Mi
# limits:
# memory: 512Mi
# -- Pod security context for the console pod. Same locked-down posture as the
# webhook: it runs no kopia, writes no files, and needs no privileges.
podSecurityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
# -- Container security context for the console.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# -- Liveness probe. On the OPS port, never the app port: the app port is
# firewalled to the proxy, and a probe there would either fail or force a hole.
livenessProbe:
httpGet:
path: /healthz
port: ops
initialDelaySeconds: 5
periodSeconds: 15
# -- Readiness probe (same passthrough as livenessProbe; `{}` drops it).
# /readyz reports the cache sync, so a console whose stores have not filled
# does not take traffic and answer with an empty fleet.
readinessProbe:
httpGet:
path: /readyz
port: ops
initialDelaySeconds: 5
periodSeconds: 10
podDisruptionBudget:
# -- PodDisruptionBudget for the console. Pair with replicaCount > 1.
enabled: false
minAvailable: 1
serviceMonitor:
# -- Create a Prometheus-Operator ServiceMonitor scraping the console's
# /metrics on the ops port (plain HTTP).
enabled: false
interval: 30s
scrapeTimeout: 10s
labels: {}
relabelings: []
metricRelabelings: []
# -- Scheduling controls (fall back to global.* when left empty).
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
priorityClassName: ""
podLabels: {}
podAnnotations: {}
# -- Extra environment variables for the console container, appended after the
# chart-managed env. Anything in `crates/ui/src/config.rs` can be set here.
extraEnv: []