Helm chart values¶
This page is a guided tour of the Kopiur chart's values.yaml — what every
setting does, which handful you actually change, and the trade-offs behind the
defaults. For installing the chart (namespaces, webhook TLS modes, CRD
lifecycle, the quickstart), see Installation; this page is the
reference for the knobs.
Single source
Every YAML block below is pulled directly from the chart's real
deploy/helm/kopiur/values.yaml
at build time (MkDocs snippets), so the documented defaults can never drift from
the file Helm actually renders. The whole annotated file is also inlined at the
bottom of this page.
The shape of the chart¶
Kopiur ships three images wired into two Deployments plus a per-Job image:
- controller — the operator (reconcilers); serves
/metrics,/healthz,/readyz. - webhook — a separate axum admission Deployment (validating + mutating).
- mover — not a Deployment. The controller stamps this image into every
Snapshot/Restore/MaintenanceJob it creates.
The controller is the chart's primary component, so its knobs live at the
root (unprefixed): replicaCount, resources, nodeSelector,
podSecurityContext, image, and so on all configure the controller. The two
auxiliary components get their own named blocks: webhook: (a full Deployment
surface) and mover: (the per-Job image only). So a root-level workload key
applies to the controller alone. You configure the controller and webhook
independently because they have different resource profiles and lifecycles.
The five values most people actually set
installScope—cluster(default) ornamespaced(least-privilege opt-down; disablesClusterRepository).image.tag(controller) /mover.image.digest— pin what runs (digest-pin the mover in prod).webhook.tls.mode—self(default),cert-manager, ormanual.monitoring.serviceMonitor.enabled/monitoring.dashboards.enabled— wire up Prometheus + Grafana.observability.otlp.enabled— add OTLP traces/logs/metrics-push on top of the pull endpoint.
Everything else has a sensible default. The sections below cover them all.
Naming overrides¶
# -- Override the chart name used in resource names (defaults to .Chart.Name = "kopiur").
nameOverride: ""
# -- Override the full release-qualified name (defaults to "<release>-kopiur").
fullnameOverride: ""
Standard Helm escape hatches. nameOverride changes the chart-name component of
generated resource names; fullnameOverride replaces the whole <release>-kopiur
prefix. Leave both empty unless you're fitting Kopiur into an existing naming
scheme.
Images¶
Each of the three images is configured next to the component it belongs to, and
each repository is a full registry + path string:
- controller — the root
imageblock (image.repository,image.tag,image.digest,image.pullPolicy). - webhook —
webhook.image.*(its ownwebhook.image.pullPolicy). - mover —
mover.image.*(its ownmover.image.pullPolicy).
# Charts published to oci://ghcr.io/home-operations/charts ship all three
# digests pinned to the release images (the in-repo defaults stay empty and
# float on tags); on a published chart set digest: "" to make tag effective.
image:
# -- Full controller image repository (registry + path).
repository: ghcr.io/home-operations/kopiur-controller
# -- Defaults to .Chart.AppVersion when empty.
tag: ""
# -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
digest: ""
# -- Image pull policy for the controller.
pullPolicy: IfNotPresent
Each image takes a tag (defaults to the chart's appVersion when empty) or a
digest. mover.image.pullPolicy sets the imagePullPolicy on every mover
Job pod the controller creates (Always / IfNotPresent / Never); when
unset, the controller infers IfNotPresent whenever an explicit mover image is
configured (so a pinned, e.g. locally-loaded, image is never re-pulled) and
otherwise leaves the cluster default in charge.
Digest-pin the mover in production
The mover image runs your data-protection Jobs. A floating :latest (or any
mutable tag) means a re-pull could silently change what runs during a backup or
restore. Set mover.image.digest to a sha256:… pin — when digest is set it
wins over tag — so a Job is always byte-for-byte reproducible.
The same advice applies to the controller and webhook, but the mover is the one
that touches your data.
imagePullSecrets (at the root, concatenated with global.imagePullSecrets) is
applied to the controller/webhook pods and the mover Jobs, so a private
registry only needs configuring once.
Install scope & CRDs¶
# =============================================================================
# Install scope
# =============================================================================
# -- "cluster" (default) or "namespaced".
# cluster — RBAC is a ClusterRole/ClusterRoleBinding. The operator manages
# kopiur.home-operations.com resources cluster-wide AND reconciles
# ClusterRepository. This is the default because a namespace-scoped Role
# silently disables the ClusterRepository kind (a cluster-scoped resource is
# out of a Role's reach), turning kopiur into a shared-backup-tier operator
# that can't do shared backup tiers.
# namespaced — RBAC is a namespace-scoped Role/RoleBinding: the operator
# manages resources only in its own namespace and ClusterRepository is NOT
# reconciled. Choose this as the explicit least-privilege opt-down for a
# single-team install where the reduced blast radius is worth losing
# cluster-scoped repositories.
installScope: cluster
installScope |
RBAC | Manages | ClusterRepository |
|---|---|---|---|
cluster (default) |
ClusterRole + ClusterRoleBinding |
cluster-wide | reconciled |
namespaced |
Role + RoleBinding |
the release namespace only | not reconciled |
cluster is the default because a namespace-scoped Role silently disables the
ClusterRepository kind — a cluster-scoped resource is out of a Role's reach,
so a namespaced install turns Kopiur into a shared-backup-tier operator that
can't do shared backup tiers. Choose namespaced as the explicit
least-privilege opt-down for a single-team install where the reduced blast radius
is worth losing cluster-scoped repositories.
How the CRDs are installed
The 8 CRDs ship in the chart's special crds/ directory: helm install
installs them, but helm upgrade never touches them (a Helm rule for the
crds/ directory). For a helm-CLI upgrade that carries a schema change you
must apply the new CRDs yourself:
A GitOps flow (CreateReplace sync) applies them automatically. There is no
install-time toggle anymore — anyone managing CRDs out of band just applies
deploy/crds/ and Helm skips the ones that already exist. See
Installation → CRD lifecycle.
Feature permissions¶
A couple of opt-in features need the operator to write Secrets in the
namespaces it manages, so each is gated behind a Helm flag — the chart does
not grant cluster-wide secrets write by default (least privilege). The flag
names match the CRD field that triggers them.
path: /readyz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
# =============================================================================
# ServiceAccount
# =============================================================================
serviceAccount:
# -- Create the ServiceAccount. Disable to bring your own.
create: true
# -- Name to use; defaults to the chart fullname when empty.
name: ""
# -- Mount the ServiceAccount token into the controller/webhook pods.
automount: true
| CRD field you set… | …needs this Helm flag | Grants secrets |
|---|---|---|
spec.credentialProjection |
features.credentialProjection.enabled |
create, patch |
spec.server (kopia web-UI) |
features.kopiaUi.enabled |
create, patch, delete |
A real blast-radius trade-off
create/delete cannot be scoped to a Secret name, so enabling either flag lets
the operator write (and, for kopiaUi, delete) a Secret in any namespace it
manages. Leave them false to keep secrets RBAC read-only. If you enable the
feature in a CR but forget the flag, the resource's .status surfaces an
actionable 403 naming the exact flag to set. See
Feature permissions for the full mapping and the
symptom→fix loop.
ServiceAccount¶
# =============================================================================
# Controller port & probes
# =============================================================================
metrics:
# -- The controller's single operational port. Rendered into KOPIUR_HTTP_ADDR
# as "[::]:<port>" (dual-stack wildcard), co-hosting /metrics, /healthz and
# /readyz. The metrics Service and the probes both target this port.
port: 8081
Set serviceAccount.create: false to bring your own. The annotations map is
where IRSA / GKE Workload Identity role bindings go, so the operator (and the
mover Jobs that inherit it) can authenticate to cloud object storage without a
static credential Secret. serviceAccount.automount (default true) controls
whether the token is mounted into the controller/webhook pods.
Controller Deployment¶
The controller's knobs live at the root of the values file (no controller.
prefix): a root-level workload key applies to the controller alone.
# =============================================================================
# Controller runtime behavior
# =============================================================================
# -- Number of controller replicas. >1 enables HA via leader election; only the
# elected leader reconciles, so deterministic jitter keeps schedules identical
# across replicas and across failover. Pair >1 with podDisruptionBudget and
# topologySpreadConstraints below to make it genuinely highly-available.
replicaCount: 1
# -- Enable leader election. Required when replicaCount > 1; harmless at 1.
leaderElection:
enabled: true
# -- Tokio worker threads for the controller runtime. The controller is I/O-bound,
# so a small pool is ample; the runtime default sizes to the host core count
# (ignoring the cgroup CPU quota), over-allocating worker threads — each a stack
# plus a malloc arena — on large nodes, inflating RSS for no throughput gain.
# Raise only for a reconcile-heavy deployment.
workerThreads: 2
# -- Use the Kubernetes WatchList streaming-list API for the controller's
# cluster-wide watches, lowering peak memory during the initial resync by
# streaming pages instead of buffering them (the startup burst the resources
# note below warns about). On by default: WatchList is GA in Kubernetes 1.34
# (beta 1.32/1.33) and the chart's kubeVersion floor is 1.32. Set `false` if
# your apiserver has the WatchList feature gate disabled — the watches degrade
# to paged lists either way, but turning it off skips the feature probe.
streamingLists: true
# @schema
# type: integer
# minimum: 0
# @schema
# -- 0 = uncapped (default); set to bound concurrent snapshot-delete batch
# Jobs across repositories (mass-deletion protection). Batching itself is the
# primary protection — one Job per repository per accumulation window, not
# one per Snapshot — so this cap is an opt-in backstop for a
# resource-constrained cluster, not a load-bearing safety mechanism: a small
# cap risks head-of-line-blocking every OTHER repository's deletions behind
# one slow/failing one. It does NOT bound how many Snapshots one batch Job
# deletes, nor does it gate whether a deletion is allowed at all — that's
# deletionProtection.threshold on the Repository/ClusterRepository.
maxConcurrentDeleteJobs: 0
# @schema
# type: integer
# minimum: 0
# maximum: 65535
# @schema
# -- Per-controller cap on concurrently running reconciles (the operator runs
# 8 controllers, so the process-wide worst case is 8x this). Bounds API-server
# load and file descriptors during re-list storms and API-server outages —
# unbounded reconcile concurrency is what let an apiserver flap exhaust the
# controller's fd table (EMFILE) within seconds. The default 8 clears a
# few-hundred-object re-list in seconds while keeping slow reconciles (hooks,
# in-process kopia ops) from starving a controller. 0 = unbounded (the
# pre-fix behavior; not recommended).
reconcileConcurrency: 8
# -- Extra CLI args appended to the controller container.
extraArgs: []
# -- Extra environment variables for the controller container.
extraEnv: []
# =============================================================================
# Controller workload & scheduling (controller only — webhook has its own)
# =============================================================================
# -- Resource requests for the controller pod.
# No limits by default. No CPU limit (CPU throttling on an operator only adds
# reconcile latency; the request reserves a fair share). No memory limit either:
# the controller's RSS can *burst* on startup/restart, not just steady state
# (~120Mi) — on (re)start it reconciles every existing resource at once, spawning
# concurrent in-process `kopia` subprocesses (whose RSS counts against this
# container's cgroup) to list/connect a repository that may hold many snapshots.
# A memory limit that doesn't cover that burst OOMKills the controller, which
# then crash-loops (OOM -> restart -> re-reconcile burst -> OOM). Set a limit
# only if you've measured your own ceiling. See crates/e2e/tests/lifecycle.rs.
resources:
requests:
cpu: 50m
The operator itself. The settings worth knowing:
replicaCount+leaderElection— run more than one replica for HA. WithleaderElection.enabled(the default) the replicas elect a leader via acoordination.k8s.io/v1Lease in the release namespace (named after the release): only the Lease holder runs reconcilers, while standby replicas stay Ready (probes and/metricsare served by every replica) and take over within ~15s (the lease duration) if the leader dies — or immediately on a graceful shutdown, where the outgoing leader releases the Lease so rolling upgrades don't stall reconciliation. A leader that loses its Lease exits and re-enters the election on restart — fail-fast beats a split-brain double-reconcile. If the leases RBAC is missing (e.g. a new image under an old chart), the controller logs a loud error and runs without election rather than crash-looping. Kopiur's deterministic jitter (derived from(scheduleUID, slot)) keeps schedules identical across replicas and across failover, so HA never doubles or skews a scheduled backup.
Don't run replicas > 1 with leaderElection disabled
leaderElection.enabled: false removes the Lease RBAC and the election
entirely — every replica then reconciles concurrently, duplicating mover Jobs
and racing status writes. Only disable it at replicaCount: 1.
streamingLists— use the Kubernetes WatchList streaming-list API for the controller's cluster-wide watches, lowering peak memory during the initial resync by streaming pages instead of buffering them (the startup burst the memory note below warns about). Defaulttrue: WatchList is beta in Kubernetes 1.32/1.33 and GA in 1.34, and the chart'skubeVersionfloor 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 setfalseonly 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 (default2). The controller is I/O-bound, so a small pool is ample; raise only for a reconcile-heavy deployment.extraVolumes/extraVolumeMounts— the way to make a filesystem backend reachable in-process (hostPath / NFS / PVC), so the controller can run its short idempotent kopia ops. The e2e harness uses a hostPath here.resources— only requests are set by default; there are intentionally no limits (thelimitsblock ships commented out). Uncomment and tune it to your own measured ceiling if you want them.podDisruptionBudget/topologySpreadConstraints— pair these withreplicaCount > 1to make HA genuine: the PDB keeps a voluntary disruption (node drain, cluster upgrade) from taking the controller to zero, and the spread constraints keep both replicas off the same node/zone. Both fall back to theirglobal.*counterparts when left empty.
Why the controller ships with no memory limit
On (re)start the controller reconciles every existing resource at once, spawning
concurrent in-process kopia subprocesses (whose RSS counts against this
container's cgroup) to list/connect repositories that may hold many snapshots.
That makes RSS burst well above steady state (~120Mi). A memory limit that
doesn't cover the burst OOMKills the controller, which then crash-loops (OOM →
restart → re-reconcile burst → OOM) — so the chart sets no limit by default. If
you add one, size it for the burst, not steady state, and measure your own
ceiling first. See crates/e2e/tests/lifecycle.rs.
Controller port & probes¶
podLabels: {}
podAnnotations: {}
# -- PodDisruptionBudget for the controller. Keeps a voluntary disruption (node
# drain, cluster upgrade) from taking the controller to zero replicas. Only
# useful with replicaCount > 1.
podDisruptionBudget:
enabled: false
minAvailable: 1
# =============================================================================
# Controller pod security (controller only)
# =============================================================================
# The controller may need a RELAXED context — e.g. runAsUser: 1000 + fsGroup to
# read a filesystem/NFS-backed repository for in-process kopia ops (which is why
# extraVolumes exists on it). The webhook is pure admission and keeps its own
# locked-down context under webhook.* so relaxing the controller never loosens
# the webhook. Non-root uid 65534 (nobody) by default — distroless:nonroot.
podSecurityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
The controller has a single operational port, metrics.port (default 8081),
which co-hosts /metrics, /healthz, and /readyz. The chart renders the
dual-stack wildcard bind address [::]:<port> into the KOPIUR_HTTP_ADDR
env, which serves both IPv4 and IPv6 kubelets (a wildcard IPv6 bind also accepts
IPv4 on Linux when net.ipv6.bindv6only=0, the default), so probes work on
either. The metrics Service and the probes both target this port.
Forcing an IPv4-only bind
On a host where IPv6 is disabled in the pod network namespace a [::] bind
fails outright. There is no listenAddr value anymore — override the bind
address directly by adding KOPIUR_HTTP_ADDR (e.g. 0.0.0.0:8081) through the
controller's extraEnv. An unparseable address fails the controller at startup
with an actionable error instead of silently falling back to the default.
livenessProbe / readinessProbe are passed through with toYaml, so you can
retune timings/thresholds, swap the scheme, or set either to {} to drop the
probe.
Admission webhook¶
The webhook is a separate Deployment + Service; the Service maps
443 → 8443.
The webhook is a full component with its own webhook: block: its own
webhook.image, webhook.port (the container port, default 8443; the chart
renders [::]:<port> into KOPIUR_WEBHOOK_ADDR and the Service maps 443 →
it), webhook.replicaCount, scheduling (webhook.nodeSelector /
tolerations / affinity / topologySpreadConstraints), its own
webhook.podDisruptionBudget, and its own security context (see Pod
security). A root-level workload key never touches it.
dashboards:
# -- Create the dashboard (a sidecar ConfigMap by default).
enabled: false
# -- Label the Grafana sidecar watches for (key: value). Adjust to your stack.
label: grafana_dashboard
labelValue: "1"
# -- Annotation setting the Grafana folder for the sidecar ConfigMap (optional).
folderAnnotation: ""
folder: ""
# -- Namespace for the dashboard object; defaults to the release namespace.
namespace: ""
# -- Extra annotations added to the dashboard object (ConfigMap or CR).
annotations: {}
# -- Extra labels added to the dashboard object (ConfigMap or CR).
labels: {}
grafanaOperator:
# -- Render a grafana-operator GrafanaDashboard CR instead of the sidecar ConfigMap.
enabled: false
# -- Allow a Grafana in any namespace to import this GrafanaDashboard.
allowCrossNamespaceImport: true
# -- Folder to create the dashboard in (Grafana folder name).
folder: ""
# -- How often grafana-operator re-checks the dashboard for updates.
resyncPeriod: "10m"
# -- spec.instanceSelector.matchLabels — selects which Grafana instance(s) load this dashboard.
matchLabels: {}
# =============================================================================
# Admission webhook (its own Deployment + Service + configs)
# =============================================================================
# A separate Deployment + Service, not folded into the controller. Everything
# the webhook workload needs lives here (its own image, port, scheduling,
# security context, PDB, spread, scrape) — a root-level key never touches it.
webhook:
# -- Deploy the webhook (Deployment + Service + Validating/Mutating configs).
# When false, validation falls back to the controller's defensive checks only.
enabled: true
replicaCount: 1
# Admission webhook image (same tag/digest rules as the controller `image`).
image:
# -- Full webhook image repository (registry + path).
repository: ghcr.io/home-operations/kopiur-webhook
# -- Defaults to .Chart.AppVersion when empty.
tag: ""
# -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
digest: ""
# -- Image pull policy for the webhook.
enabled— whenfalse, validation falls back to the controller's defensive checks only. Not recommended; the webhook is what makes invalid states unrepresentable at admission time.failurePolicy: Fail— fail-closed is the default and the right call for a backup operator: if the webhook is down, reject the write rather than silently admit an unvalidatedSnapshot. That makeswebhook.podDisruptionBudgetthe most important PDB to enable in HA.webhook.serviceMonitor— the webhook serves/metricson its TLS port; scraping it needsinsecureSkipVerify(it serves a self-signed cert by default). This stays underwebhook:(notmonitoring:) because it's an HTTPS scrape of the webhook's own port.
Webhook TLS¶
- ALL
# -- Extra environment variables for the webhook container (list of
# `{name, value}` / `{name, valueFrom}` entries), appended after the
# operator-managed env. Mirrors the root `extraEnv`.
extraEnv: []
# -- Liveness probe for the webhook container. Passed through with `toYaml`
# (retune timings/thresholds or swap the probe; `{}` drops it). The webhook
# only serves HTTPS, so `scheme: HTTPS` on the named `https` port.
livenessProbe:
httpGet:
path: /healthz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 15
# -- Readiness probe for the webhook container (same passthrough as
# livenessProbe; set to `{}` to drop it).
readinessProbe:
httpGet:
path: /readyz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
# The webhook serves /metrics on its TLS port (kopiur_webhook_admission_total).
# This ServiceMonitor stays on the webhook (not under monitoring:) because it
# is an HTTPS scrape of the webhook's own port — a different scrape config from
# the controller's plain-HTTP one.
serviceMonitor:
# -- Create a ServiceMonitor scraping the webhook's /metrics over HTTPS.
enabled: false
The webhook always serves TLS (Kubernetes requires HTTPS for admission);
webhook.tls.mode only chooses how the serving cert is provisioned:
mode |
What happens | Needs cert-manager? |
|---|---|---|
self (default) |
Operator mints its own CA + cert, writes the Secret, injects the caBundle, auto-rotates. |
No |
cert-manager |
cert-manager issues the cert; its ca-injector populates the caBundle. |
Yes |
manual |
You pre-create the Secret and set webhook.caBundle (base64 PEM) yourself. |
No |
The default self mode needs zero configuration and no external dependency.
Full walkthrough with --set commands for each mode: Installation → Webhook
TLS.
Monitoring (Prometheus & Grafana)¶
# =============================================================================
# OpenTelemetry (OTLP) — optional push of traces + logs (and a metrics push)
# =============================================================================
# Separate from `logging` above (the always-on stdout stream): observability.otlp
# is the OPTIONAL push path. When enabled, the controller, webhook, and mover Jobs
# export OTLP to the configured collector (the controller passes the same OTEL_*
# env to movers). Metrics are ALSO always available via the Prometheus /metrics
# pull endpoint; OTLP just adds a push path plus traces and logs. Env var names
# match crates/telemetry/src/env.rs.
observability:
otlp:
# -- Enable OTLP export (sets OTEL_EXPORTER_OTLP_ENDPOINT on all components).
enabled: false
# -- Collector gRPC endpoint. Required when enabled. Only gRPC is compiled in.
endpoint: "http://otel-collector.observability.svc:4317"
# -- OTEL_EXPORTER_OTLP_PROTOCOL (only "grpc" is supported by this build).
protocol: "grpc"
# -- OTEL_EXPORTER_OTLP_HEADERS, e.g. "authorization=Bearer xyz". Empty to omit.
headers: ""
# -- Fail-fast on telemetry misconfiguration instead of degrading to fmt+pull.
strict: false
# -- Extra raw env (e.g. OTEL_TRACES_SAMPLER) added to every component.
extraEnv: []
# =============================================================================
# Monitoring (Prometheus + Grafana)
# =============================================================================
# The controller-scrape ServiceMonitor, the alerting PrometheusRule, and the
# Grafana dashboard(s), grouped under one umbrella. NOTE: the webhook's own
# ServiceMonitor is NOT here — it lives at webhook.serviceMonitor because it is
# an HTTPS scrape of the webhook's TLS port, a materially different scrape config
# that moves with the webhook component, not this umbrella. The split is
# deliberate.
monitoring:
serviceMonitor:
# -- Create a Prometheus-Operator ServiceMonitor scraping the controller's
# /metrics (plain HTTP). Requires the ServiceMonitor CRD to exist.
enabled: false
# -- Scrape interval.
interval: 30s
# -- Scrape timeout.
scrapeTimeout: 10s
# -- Extra labels (e.g. to match your Prometheus serviceMonitorSelector).
labels: {}
# -- Relabelings / metricRelabelings passed through verbatim.
relabelings: []
metricRelabelings: []
prometheusRule:
# -- Create a Prometheus-Operator PrometheusRule with kopiur alerts.
enabled: false
# -- Extra labels (e.g. to match your Prometheus ruleSelector).
labels: {}
# -- Age (seconds) after which a SnapshotPolicy's last success is "stale".
All metrics are under the kopiur_ namespace and served via a Prometheus pull
endpoint on the controller's port (metrics.port). The controller's metrics
Service is always created — that listener co-hosts /metrics with
/healthz + /readyz, so there's nothing to disable. The monitoring: block
additionally wires up the Prometheus Operator and Grafana:
monitoring.serviceMonitor.enabled— create aServiceMonitorscraping the controller's/metrics(plain HTTP; needs the Prometheus-Operator CRDs); set.labelsto match yourserviceMonitorSelector. TheServiceMonitorsetshonorLabels: true, so eachkopiur_*series keeps its ownnamespacelabel — the namespace of the CR the metric describes — instead of having it overwritten by the controller's namespace (which Prometheus would otherwise move aside toexported_namespace).monitoring.prometheusRule.enabled— ship the kopiur alert rules.backupStaleAfterSeconds(default 48h) is the age after which aSnapshotPolicy's last success is considered stale. The backup-failure alerts are recovery-aware:KopiurLastBackupFailedfires while aSnapshotPolicy's most recent completed backup failed and resolves automatically the moment a newer backup succeeds;KopiurSnapshotFailed(per-Snapshot) stops firing for a retained FailedSnapshotonce its policy's latest backup succeeds — aSnapshotwith no policy reference keeps the always-fire behavior instead.KopiurBackupStalestill covers the policy that has never succeeded or whose successfulSnapshotCRs were all pruned. Both recovery-aware alerts waitfor: 10matseverity: warning.monitoring.dashboards.enabled— ship the dashboard. By default it's a sidecar-discoverableConfigMap(source:deploy/dashboards/kopiur.json); flipmonitoring.dashboards.grafanaOperator.enabledto render a grafana-operatorGrafanaDashboardCR from the very same JSON instead.
honorLabels: true changes the namespace label
With honorLabels: true, the namespace label on every scraped kopiur_* series
is now the CR's namespace rather than the controller's. If you keyed external
dashboards or queries on exported_namespace (where the CR namespace previously
landed), update them to namespace. The dashboard this chart ships already assumed
CR-namespace semantics, so this fixes its $namespace dropdown rather than breaking
it.
The webhook's own HTTPS scrape lives separately under
webhook.serviceMonitor.
OpenTelemetry (OTLP)¶
# never reads them (`--local` is the exception and additionally needs `get
# secrets`, granted separately). The chart only renders the ClusterRole; bind
# it to your users/groups with your own (Cluster)RoleBinding.
browseRole: false
# =============================================================================
# Logging (stdout / `kubectl logs`, always on)
# =============================================================================
# Controls the fmt logging that every component writes to stdout — the stream
# `kubectl logs` shows. The controller passes RUST_LOG + KOPIUR_LOG_FORMAT
# through to mover Jobs, so a mover honors the same level and format. This is a
# chart-wide concern (all three components honor it), which is why it stays a
# named top-level block rather than folding under the controller. Env var names
# match crates/telemetry/src/env.rs. (For pushing logs/traces to a collector,
Off by default. Metrics are always available via the /metrics pull endpoint;
turning on OTLP adds a push path plus traces and logs. When enabled, the
controller, webhook, and mover Jobs all export to the configured collector (the
controller forwards the same OTEL_* env to the movers it spawns). Only gRPC is
compiled in, so endpoint must point at the collector's gRPC port (4317).
observability.otlp.strict makes telemetry misconfiguration fail-fast instead of
degrading to fmt+pull — leave it false unless you want a broken collector to
block startup. See Observability for the full metric list
and a sample collector config.
Logging¶
enabled: false
kopiaUi:
# -- Grant the operator `secrets` create+patch+delete so `spec.server` (the
# kopia web-UI) works: it creates a generated-auth Secret, mirrors a
# ClusterRepository's credentials into the server namespace, and deletes both on
# teardown. SECURITY TRADE-OFF: grants unscoped create/delete on Secrets in any
# namespace the operator manages.
enabled: false
Controls the stdout (kubectl logs) logging every component writes. The
controller passes RUST_LOG + KOPIUR_LOG_FORMAT through to mover Jobs, so a
mover honors the same level and format.
level—RUST_LOG-style, defaultinfo. Per-target works too:"info,kopia=debug"surfaces kopia's own progress in mover logs.format—text(human-readable, default) orjson(one structured object per line for Loki / ELK / Datadog).
Flags & environment variables¶
You normally configure Kopiur through the Helm values above — the chart turns
them into environment variables on the controller and webhook Deployments.
Under the hood, every one of those knobs is also a command-line flag on the
binary, with the env var as its fallback (flag > env var > built-in
default). Run any binary with --help for the full, self-documenting list:
$ kopiur-controller --help # every knob, its KOPIUR_* env var, and its default
$ kopiur-webhook --help
$ kopiur-mover --help # ready / serve subcommands + run-once mode
The flags matter in two situations:
- Running a binary outside the chart (local development, a custom
deployment):
kopiur-controller --mover-image ghcr.io/… --http-addr '[::]:8081'beats exporting env vars by hand. extraArgs(the controller's) — extra flags are now actually parsed. That cuts both ways: a valid flag works, and an unknown or malformed one fails the container at startup with an actionable usage error (previously extra args were silently ignored).
Malformed values now fail loudly at startup
A typo'd configuration value used to be silently swallowed: a garbage
KOPIUR_WORKER_THREADS fell back to the default, and a misspelled boolean
(e.g. KOPIUR_STREAMING_LISTS=ture) silently meant "off". Every value is now
validated at startup — an unparseable number, boolean, socket address,
role kind, or pull policy stops the process with a message naming the variable,
the accepted values, and the fix. Chart-rendered values are unaffected (the
chart only emits valid ones); hand-set extraEnv / extraArgs values get the
loud failure instead of a silent mis-configuration.
Pod security¶
Security contexts are now per-component. The root podSecurityContext /
securityContext are the controller's; the webhook carries its own
webhook.podSecurityContext / webhook.securityContext with the same restricted
defaults, so relaxing the controller (e.g. runAsUser: 1000 + fsGroup to read
a filesystem/NFS-backed repository for in-process kopia ops) never loosens the
webhook.
# No limits are set by default. Uncomment and tune to your own measured
# ceiling if you want them (a memory limit below the startup burst OOM-loops
# the controller — see the note above):
# limits:
# memory: 1Gi
# -- Extra volumes on the controller pod. Use this to make a filesystem-backend
# repository reachable in-process (hostPath/NFS/PVC) so the controller can run
# short idempotent kopia ops directly. The e2e harness uses a hostPath here.
extraVolumes: []
# -- Extra volume mounts on the controller container (pairs with extraVolumes).
extraVolumeMounts: []
# -- Scheduling controls (fall back to global.* when left empty).
nodeSelector: {}
tolerations: []
affinity: {}
# -- Spread controller replicas across nodes/zones. Only meaningful with
# replicaCount > 1; pairs with podDisruptionBudget so a drain can't collapse
# both replicas onto one node and then evict them together.
topologySpreadConstraints: []
# -- Pod-level priority class.
priorityClassName: ""
Defaults for both: non-root uid/gid 65534 (nobody), runAsNonRoot, a
RuntimeDefault seccomp profile, no privilege escalation, a read-only root
filesystem, and all capabilities dropped (the images are distroless:nonroot).
The webhook's own block:
pullPolicy: IfNotPresent
# -- failurePolicy for both webhook configurations: Fail (fail-closed,
# recommended for a backup operator) or Ignore. Fail means a webhook outage
# blocks kopiur CR writes — see podDisruptionBudget below for why HA matters.
failurePolicy: Fail
# -- timeoutSeconds for admission requests (1..30).
timeoutSeconds: 10
# -- The webhook's container port. Rendered into KOPIUR_WEBHOOK_ADDR as
# "[::]:<port>" (dual-stack wildcard); the Service maps 443 -> this port.
port: 8443
# No limits by default (see the controller resources note). The webhook links
# the same OpenTelemetry/OTLP stack but does no kopia work, so it stays light;
# only the requests are set to reserve a fair share.
resources:
requests:
cpu: 25m
memory: 64Mi
This is the operator's security context, not the mover's
These podSecurityContext / securityContext blocks govern the controller and
webhook pods. The UID/GID that a mover Job runs as — which has to match the
ownership of the data being backed up so it can read it — is configured
per-SnapshotPolicy / per-Restore, not here. See
Permissions, UID & GID and Security context.
A ready-made observability overlay¶
The repo ships an overlay that flips the whole metrics + dashboard surface on at
once. Pass it with helm -f:
# Example Helm VALUES overlay that turns on Kopiur's full observability surface.
# (This is a Helm values file — pass it with `helm -f` — not a Custom Resource
# like the manifests under deploy/examples/.)
#
# helm upgrade --install kopiur deploy/helm/kopiur -n kopiur-system \
# -f deploy/observability-values.yaml
#
# See docs/dev/observability.md for the full metric list and details.
# The controller's metrics Service is always created; the monitoring umbrella
# wires up the Prometheus-Operator objects and the Grafana dashboard.
monitoring:
serviceMonitor:
# Requires the Prometheus-Operator CRDs. Add labels here if your Prometheus
# uses a serviceMonitorSelector.
enabled: true
# labels:
# release: kube-prometheus-stack
prometheusRule:
# Ships the kopiur alert rules (backup stale/failed, repo not ready, etc.).
enabled: true
# A SnapshotPolicy is "stale" if its last success is older than this (seconds).
backupStaleAfterSeconds: 172800 # 48h
# Ship the Grafana dashboard as a ConfigMap the Grafana sidecar auto-discovers.
# Adjust `label`/`labelValue` to match your sidecar's watch label.
dashboards:
enabled: true
label: grafana_dashboard
labelValue: "1"
# Scrape the webhook's /metrics (served over its TLS port; self-signed by
# default). This stays under `webhook:` — it's an HTTPS scrape of the webhook's
# own port, distinct from the controller scrape in `monitoring:` above.
webhook:
serviceMonitor:
enabled: true
insecureSkipVerify: true
# Optional: export OTLP traces + logs + a metrics push to a collector. Leave
# disabled to run Prometheus-pull only. Only gRPC is supported — point at the
# collector's gRPC port (4317).
observability:
otlp:
enabled: false
endpoint: "http://otel-collector.observability.svc:4317"
protocol: "grpc"
# headers: "authorization=Bearer <token>"
# strict: false # true = fail-fast on telemetry misconfiguration
# extraEnv: # e.g. tail-sampling / resource attributes
# - name: OTEL_TRACES_SAMPLER
# value: parentbased_traceidratio
# - name: OTEL_TRACES_SAMPLER_ARG
# value: "0.1"
helm upgrade --install kopiur oci://ghcr.io/home-operations/charts/kopiur -n kopiur-system \
-f deploy/observability-values.yaml
The complete values.yaml¶
The whole annotated file, exactly as the chart ships it:
Full values.yaml (click to expand)
# Default values for the kopiur chart.
# This is a YAML-formatted file. Every key is documented inline.
# See deploy/helm/kopiur/README.md for install modes and worked examples.
#
# COMPONENT MODEL (read this first): the controller is the chart's primary
# component, so its knobs live at the ROOT (unprefixed) — `replicaCount`,
# `resources`, `nodeSelector`, `podSecurityContext`, `image`, and so on all
# configure the CONTROLLER. The two auxiliary components get their own named
# blocks: `webhook:` (a full Deployment surface) and `mover:` (the per-Job
# image only). So a root-level workload key applies to the controller ALONE —
# the webhook has its own under `webhook:`, and the mover, a controller-stamped
# Job, takes no workload/scheduling keys (only `mover.image`).
# -- Override the chart name used in resource names (defaults to .Chart.Name = "kopiur").
nameOverride: ""
# -- Override the full release-qualified name (defaults to "<release>-kopiur").
fullnameOverride: ""
# =============================================================================
# Global (chart-wide fallbacks + umbrella-injectable subset)
# =============================================================================
# kopiur has no subcharts, so `global` does not auto-propagate anywhere on its
# own. Its two jobs: (a) kopiur's own templates read `global.*` as a shared
# fallback for the controller (root) and the webhook, so you set a fleet-wide
# default once instead of repeating it per component; (b) if kopiur is ever
# wrapped in an umbrella chart, Helm merges the umbrella's `global` down and
# kopiur inherits these for free. Merge rule: list-valued globals
# (imagePullSecrets, tolerations, topologySpreadConstraints) are CONCATENATED
# with the component value; map-valued globals (nodeSelector, affinity) are used
# only when the component leaves its own value empty; commonLabels are added to
# every rendered object.
global:
# -- Concatenated with the top-level imagePullSecrets; reaches controller,
# webhook, and mover Jobs.
imagePullSecrets: []
# -- Scheduling defaults every kopiur pod inherits unless the component
# (root = controller, webhook.*) sets its own.
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
# -- Labels stamped on every rendered object (fleet-wide labelling).
commonLabels: {}
# =============================================================================
# Install scope
# =============================================================================
# -- "cluster" (default) or "namespaced".
# cluster — RBAC is a ClusterRole/ClusterRoleBinding. The operator manages
# kopiur.home-operations.com resources cluster-wide AND reconciles
# ClusterRepository. This is the default because a namespace-scoped Role
# silently disables the ClusterRepository kind (a cluster-scoped resource is
# out of a Role's reach), turning kopiur into a shared-backup-tier operator
# that can't do shared backup tiers.
# namespaced — RBAC is a namespace-scoped Role/RoleBinding: the operator
# manages resources only in its own namespace and ClusterRepository is NOT
# reconciled. Choose this as the explicit least-privilege opt-down for a
# single-team install where the reduced blast radius is worth losing
# cluster-scoped repositories.
installScope: cluster
# =============================================================================
# Controller image
# =============================================================================
# Three images ship with kopiur:
# * controller — the operator (reconcilers); exposes /metrics. Configured here.
# * webhook — the axum admission server; see webhook.image below.
# * mover — the per-Job image (one Job per Backup/Restore/Maintenance
# run); see mover.image below.
# When `digest` is set it wins over `tag` (tag defaults to .Chart.AppVersion).
# Charts published to oci://ghcr.io/home-operations/charts ship all three
# digests pinned to the release images (the in-repo defaults stay empty and
# float on tags); on a published chart set digest: "" to make tag effective.
image:
# -- Full controller image repository (registry + path).
repository: ghcr.io/home-operations/kopiur-controller
# -- Defaults to .Chart.AppVersion when empty.
tag: ""
# -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
digest: ""
# -- Image pull policy for the controller.
pullPolicy: IfNotPresent
# -- Image pull secrets for the controller pod (concatenated with
# global.imagePullSecrets; also reaches the webhook pod and mover Jobs).
imagePullSecrets: []
# - name: my-registry-creds
# =============================================================================
# Controller runtime behavior
# =============================================================================
# -- Number of controller replicas. >1 enables HA via leader election; only the
# elected leader reconciles, so deterministic jitter keeps schedules identical
# across replicas and across failover. Pair >1 with podDisruptionBudget and
# topologySpreadConstraints below to make it genuinely highly-available.
replicaCount: 1
# -- Enable leader election. Required when replicaCount > 1; harmless at 1.
leaderElection:
enabled: true
# -- Tokio worker threads for the controller runtime. The controller is I/O-bound,
# so a small pool is ample; the runtime default sizes to the host core count
# (ignoring the cgroup CPU quota), over-allocating worker threads — each a stack
# plus a malloc arena — on large nodes, inflating RSS for no throughput gain.
# Raise only for a reconcile-heavy deployment.
workerThreads: 2
# -- Use the Kubernetes WatchList streaming-list API for the controller's
# cluster-wide watches, lowering peak memory during the initial resync by
# streaming pages instead of buffering them (the startup burst the resources
# note below warns about). On by default: WatchList is GA in Kubernetes 1.34
# (beta 1.32/1.33) and the chart's kubeVersion floor is 1.32. Set `false` if
# your apiserver has the WatchList feature gate disabled — the watches degrade
# to paged lists either way, but turning it off skips the feature probe.
streamingLists: true
# @schema
# type: integer
# minimum: 0
# @schema
# -- 0 = uncapped (default); set to bound concurrent snapshot-delete batch
# Jobs across repositories (mass-deletion protection). Batching itself is the
# primary protection — one Job per repository per accumulation window, not
# one per Snapshot — so this cap is an opt-in backstop for a
# resource-constrained cluster, not a load-bearing safety mechanism: a small
# cap risks head-of-line-blocking every OTHER repository's deletions behind
# one slow/failing one. It does NOT bound how many Snapshots one batch Job
# deletes, nor does it gate whether a deletion is allowed at all — that's
# deletionProtection.threshold on the Repository/ClusterRepository.
maxConcurrentDeleteJobs: 0
# @schema
# type: integer
# minimum: 0
# maximum: 65535
# @schema
# -- Per-controller cap on concurrently running reconciles (the operator runs
# 8 controllers, so the process-wide worst case is 8x this). Bounds API-server
# load and file descriptors during re-list storms and API-server outages —
# unbounded reconcile concurrency is what let an apiserver flap exhaust the
# controller's fd table (EMFILE) within seconds. The default 8 clears a
# few-hundred-object re-list in seconds while keeping slow reconciles (hooks,
# in-process kopia ops) from starving a controller. 0 = unbounded (the
# pre-fix behavior; not recommended).
reconcileConcurrency: 8
# -- Extra CLI args appended to the controller container.
extraArgs: []
# -- Extra environment variables for the controller container.
extraEnv: []
# =============================================================================
# Controller workload & scheduling (controller only — webhook has its own)
# =============================================================================
# -- Resource requests for the controller pod.
# No limits by default. No CPU limit (CPU throttling on an operator only adds
# reconcile latency; the request reserves a fair share). No memory limit either:
# the controller's RSS can *burst* on startup/restart, not just steady state
# (~120Mi) — on (re)start it reconciles every existing resource at once, spawning
# concurrent in-process `kopia` subprocesses (whose RSS counts against this
# container's cgroup) to list/connect a repository that may hold many snapshots.
# A memory limit that doesn't cover that burst OOMKills the controller, which
# then crash-loops (OOM -> restart -> re-reconcile burst -> OOM). Set a limit
# only if you've measured your own ceiling. See crates/e2e/tests/lifecycle.rs.
resources:
requests:
cpu: 50m
memory: 128Mi
# No limits are set by default. Uncomment and tune to your own measured
# ceiling if you want them (a memory limit below the startup burst OOM-loops
# the controller — see the note above):
# limits:
# memory: 1Gi
# -- Extra volumes on the controller pod. Use this to make a filesystem-backend
# repository reachable in-process (hostPath/NFS/PVC) so the controller can run
# short idempotent kopia ops directly. The e2e harness uses a hostPath here.
extraVolumes: []
# -- Extra volume mounts on the controller container (pairs with extraVolumes).
extraVolumeMounts: []
# -- Scheduling controls (fall back to global.* when left empty).
nodeSelector: {}
tolerations: []
affinity: {}
# -- Spread controller replicas across nodes/zones. Only meaningful with
# replicaCount > 1; pairs with podDisruptionBudget so a drain can't collapse
# both replicas onto one node and then evict them together.
topologySpreadConstraints: []
# -- Pod-level priority class.
priorityClassName: ""
# -- Extra pod labels / annotations.
podLabels: {}
podAnnotations: {}
# -- PodDisruptionBudget for the controller. Keeps a voluntary disruption (node
# drain, cluster upgrade) from taking the controller to zero replicas. Only
# useful with replicaCount > 1.
podDisruptionBudget:
enabled: false
minAvailable: 1
# =============================================================================
# Controller pod security (controller only)
# =============================================================================
# The controller may need a RELAXED context — e.g. runAsUser: 1000 + fsGroup to
# read a filesystem/NFS-backed repository for in-process kopia ops (which is why
# extraVolumes exists on it). The webhook is pure admission and keeps its own
# locked-down context under webhook.* so relaxing the controller never loosens
# the webhook. Non-root uid 65534 (nobody) by default — distroless:nonroot.
podSecurityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# =============================================================================
# Controller port & probes
# =============================================================================
metrics:
# -- The controller's single operational port. Rendered into KOPIUR_HTTP_ADDR
# as "[::]:<port>" (dual-stack wildcard), co-hosting /metrics, /healthz and
# /readyz. The metrics Service and the probes both target this port.
port: 8081
# -- Liveness probe for the controller container. The whole probe is passed
# through with `toYaml`, so you can retune timings/thresholds or swap the scheme
# entirely; set to `{}` to drop the probe. `port: metrics` is the named
# container port (metrics.port above).
livenessProbe:
httpGet:
path: /healthz
port: metrics
initialDelaySeconds: 10
periodSeconds: 15
# -- Readiness probe for the controller container (same passthrough as
# livenessProbe; set to `{}` to drop it).
readinessProbe:
httpGet:
path: /readyz
port: metrics
initialDelaySeconds: 5
periodSeconds: 10
# =============================================================================
# ServiceAccount
# =============================================================================
serviceAccount:
# -- Create the ServiceAccount. Disable to bring your own.
create: true
# -- Name to use; defaults to the chart fullname when empty.
name: ""
# -- Mount the ServiceAccount token into the controller/webhook pods.
automount: true
# -- Extra annotations (e.g. IRSA / Workload Identity role bindings).
annotations: {}
# =============================================================================
# Feature permissions
# =============================================================================
# A few opt-in features need the operator to WRITE Secrets in the namespaces it
# manages, so each grants extra RBAC. All are OFF by default (least privilege):
# the chart does not hand out cluster-wide `secrets` write until you turn on the
# feature you use. The flag names match the CRD fields that trigger them.
#
# Symptom → fix: if you enable the feature in a CR but forget the flag here, the
# resource's `.status` shows an actionable condition like
# "... (HTTP 403). ... Fix: set features.<name>.enabled: true ..."
# Set the matching flag below and the operator heals on the next reconcile.
# See docs/feature-permissions.md for the full mapping and security trade-offs.
features:
credentialProjection:
# -- Grant the operator `secrets` create+patch+delete so
# `spec.credentialProjection` works: the operator copies a repository's
# credential Secret(s) into each mover Job's namespace (the chief win is a shared
# ClusterRepository whose Secret is pinned to one namespace) and cleans its
# copies up again (legacy-copy sweep, reap-on-shrink, reap-on-disable).
# SECURITY TRADE-OFF: `create` cannot be scoped to a Secret name, so the
# operator can write a Secret in any namespace it manages.
enabled: false
kopiaUi:
# -- Grant the operator `secrets` create+patch+delete so `spec.server` (the
# kopia web-UI) works: it creates a generated-auth Secret, mirrors a
# ClusterRepository's credentials into the server namespace, and deletes both on
# teardown. SECURITY TRADE-OFF: grants unscoped create/delete on Secrets in any
# namespace the operator manages.
enabled: false
# =============================================================================
# Extra RBAC
# =============================================================================
rbac:
# -- Render an OPT-IN ClusterRole "<release>-browse" carrying exactly what a
# human needs to run the `kubectl kopiur ls/cat/download/browse` data-plane:
# read snapshots/repositories, create/delete the session Job + ConfigMap, and
# exec into the session pod. It deliberately grants NO access to Secrets —
# the session pod loads the repository credentials itself, so a browsing user
# never reads them (`--local` is the exception and additionally needs `get
# secrets`, granted separately). The chart only renders the ClusterRole; bind
# it to your users/groups with your own (Cluster)RoleBinding.
browseRole: false
# =============================================================================
# Logging (stdout / `kubectl logs`, always on)
# =============================================================================
# Controls the fmt logging that every component writes to stdout — the stream
# `kubectl logs` shows. The controller passes RUST_LOG + KOPIUR_LOG_FORMAT
# through to mover Jobs, so a mover honors the same level and format. This is a
# chart-wide concern (all three components honor it), which is why it stays a
# named top-level block rather than folding under the controller. Env var names
# match crates/telemetry/src/env.rs. (For pushing logs/traces to a collector,
# see observability.otlp below — a different, optional path.)
logging:
# -- Log level / filter directive (RUST_LOG style: error|warn|info|debug|trace;
# per-target works too, e.g. "info,kopia=debug" to see kopia's own progress in
# mover logs).
level: info
# -- Console format: "text" (human-readable, default) or "json" (one structured
# object per line for Loki/ELK/Datadog). Unknown values degrade to text.
format: text
# =============================================================================
# OpenTelemetry (OTLP) — optional push of traces + logs (and a metrics push)
# =============================================================================
# Separate from `logging` above (the always-on stdout stream): observability.otlp
# is the OPTIONAL push path. When enabled, the controller, webhook, and mover Jobs
# export OTLP to the configured collector (the controller passes the same OTEL_*
# env to movers). Metrics are ALSO always available via the Prometheus /metrics
# pull endpoint; OTLP just adds a push path plus traces and logs. Env var names
# match crates/telemetry/src/env.rs.
observability:
otlp:
# -- Enable OTLP export (sets OTEL_EXPORTER_OTLP_ENDPOINT on all components).
enabled: false
# -- Collector gRPC endpoint. Required when enabled. Only gRPC is compiled in.
endpoint: "http://otel-collector.observability.svc:4317"
# -- OTEL_EXPORTER_OTLP_PROTOCOL (only "grpc" is supported by this build).
protocol: "grpc"
# -- OTEL_EXPORTER_OTLP_HEADERS, e.g. "authorization=Bearer xyz". Empty to omit.
headers: ""
# -- Fail-fast on telemetry misconfiguration instead of degrading to fmt+pull.
strict: false
# -- Extra raw env (e.g. OTEL_TRACES_SAMPLER) added to every component.
extraEnv: []
# =============================================================================
# Monitoring (Prometheus + Grafana)
# =============================================================================
# The controller-scrape ServiceMonitor, the alerting PrometheusRule, and the
# Grafana dashboard(s), grouped under one umbrella. NOTE: the webhook's own
# ServiceMonitor is NOT here — it lives at webhook.serviceMonitor because it is
# an HTTPS scrape of the webhook's TLS port, a materially different scrape config
# that moves with the webhook component, not this umbrella. The split is
# deliberate.
monitoring:
serviceMonitor:
# -- Create a Prometheus-Operator ServiceMonitor scraping the controller's
# /metrics (plain HTTP). Requires the ServiceMonitor CRD to exist.
enabled: false
# -- Scrape interval.
interval: 30s
# -- Scrape timeout.
scrapeTimeout: 10s
# -- Extra labels (e.g. to match your Prometheus serviceMonitorSelector).
labels: {}
# -- Relabelings / metricRelabelings passed through verbatim.
relabelings: []
metricRelabelings: []
prometheusRule:
# -- Create a Prometheus-Operator PrometheusRule with kopiur alerts.
enabled: false
# -- Extra labels (e.g. to match your Prometheus ruleSelector).
labels: {}
# -- Age (seconds) after which a SnapshotPolicy's last success is "stale".
backupStaleAfterSeconds: 172800 # 48h
# -- Grafana dashboard(s) for the kopiur fleet. The same JSON lives in
# deploy/dashboards/kopiur.json (the single source of truth, copied into the
# chart by `cargo xtask gen-all`) for manual import. By default it ships as a
# ConfigMap labeled for the Grafana sidecar to auto-discover; flip
# grafanaOperator.enabled to render a grafana-operator GrafanaDashboard CR from
# the very same JSON instead.
dashboards:
# -- Create the dashboard (a sidecar ConfigMap by default).
enabled: false
# -- Label the Grafana sidecar watches for (key: value). Adjust to your stack.
label: grafana_dashboard
labelValue: "1"
# -- Annotation setting the Grafana folder for the sidecar ConfigMap (optional).
folderAnnotation: ""
folder: ""
# -- Namespace for the dashboard object; defaults to the release namespace.
namespace: ""
# -- Extra annotations added to the dashboard object (ConfigMap or CR).
annotations: {}
# -- Extra labels added to the dashboard object (ConfigMap or CR).
labels: {}
grafanaOperator:
# -- Render a grafana-operator GrafanaDashboard CR instead of the sidecar ConfigMap.
enabled: false
# -- Allow a Grafana in any namespace to import this GrafanaDashboard.
allowCrossNamespaceImport: true
# -- Folder to create the dashboard in (Grafana folder name).
folder: ""
# -- How often grafana-operator re-checks the dashboard for updates.
resyncPeriod: "10m"
# -- spec.instanceSelector.matchLabels — selects which Grafana instance(s) load this dashboard.
matchLabels: {}
# =============================================================================
# Admission webhook (its own Deployment + Service + configs)
# =============================================================================
# A separate Deployment + Service, not folded into the controller. Everything
# the webhook workload needs lives here (its own image, port, scheduling,
# security context, PDB, spread, scrape) — a root-level key never touches it.
webhook:
# -- Deploy the webhook (Deployment + Service + Validating/Mutating configs).
# When false, validation falls back to the controller's defensive checks only.
enabled: true
replicaCount: 1
# Admission webhook image (same tag/digest rules as the controller `image`).
image:
# -- Full webhook image repository (registry + path).
repository: ghcr.io/home-operations/kopiur-webhook
# -- Defaults to .Chart.AppVersion when empty.
tag: ""
# -- Pin by digest (e.g. "sha256:..."); takes precedence over tag.
digest: ""
# -- Image pull policy for the webhook.
pullPolicy: IfNotPresent
# -- failurePolicy for both webhook configurations: Fail (fail-closed,
# recommended for a backup operator) or Ignore. Fail means a webhook outage
# blocks kopiur CR writes — see podDisruptionBudget below for why HA matters.
failurePolicy: Fail
# -- timeoutSeconds for admission requests (1..30).
timeoutSeconds: 10
# -- The webhook's container port. Rendered into KOPIUR_WEBHOOK_ADDR as
# "[::]:<port>" (dual-stack wildcard); the Service maps 443 -> this port.
port: 8443
# No limits by default (see the controller resources note). The webhook links
# the same OpenTelemetry/OTLP stack but does no kopia work, so it stays light;
# only the requests are set to reserve a fair share.
resources:
requests:
cpu: 25m
memory: 64Mi
# No limits are set by default. Uncomment and tune if you want them:
# limits:
# memory: 512Mi
# -- Scheduling controls (fall back to global.* when left empty).
nodeSelector: {}
tolerations: []
affinity: {}
# -- Spread webhook replicas across nodes/zones (pairs with podDisruptionBudget).
topologySpreadConstraints: []
priorityClassName: ""
podLabels: {}
podAnnotations: {}
# -- PodDisruptionBudget for the webhook — the most important one to enable in
# HA. With failurePolicy: Fail, a node drain that evicts the only webhook
# replica blocks every kopiur CR write until it reschedules; a PDB with
# replicaCount > 1 keeps one replica serving through the drain.
podDisruptionBudget:
enabled: false
minAvailable: 1
# -- Pod security context for the webhook pod. Kept at the most locked-down
# posture (the webhook is pure admission and never touches a repository), so
# relaxing the controller's context never loosens the webhook.
podSecurityContext:
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
# -- Container security context for the webhook.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
# -- Extra environment variables for the webhook container (list of
# `{name, value}` / `{name, valueFrom}` entries), appended after the
# operator-managed env. Mirrors the root `extraEnv`.
extraEnv: []
# -- Liveness probe for the webhook container. Passed through with `toYaml`
# (retune timings/thresholds or swap the probe; `{}` drops it). The webhook
# only serves HTTPS, so `scheme: HTTPS` on the named `https` port.
livenessProbe:
httpGet:
path: /healthz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 15
# -- Readiness probe for the webhook container (same passthrough as
# livenessProbe; set to `{}` to drop it).
readinessProbe:
httpGet:
path: /readyz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
# The webhook serves /metrics on its TLS port (kopiur_webhook_admission_total).
# This ServiceMonitor stays on the webhook (not under monitoring:) because it
# is an HTTPS scrape of the webhook's own port — a different scrape config from
# the controller's plain-HTTP one.
serviceMonitor:
# -- Create a ServiceMonitor scraping the webhook's /metrics over HTTPS.
enabled: false
interval: 30s
scrapeTimeout: 10s
labels: {}
# -- The webhook serves a self-signed cert, so skip verification by default.
insecureSkipVerify: true
# TLS for the webhook server. The webhook ALWAYS serves TLS (k8s requires
# HTTPS for admission). How the serving cert is provisioned is chosen by
# tls.mode below.
tls:
# -- How the webhook serving certificate is provisioned and trusted. One of:
# self — the operator mints its own CA + serving cert, writes the
# Secret, and injects caBundle into the webhook
# configurations itself. No cert-manager, no manual steps,
# and the leaf is auto-rotated before expiry. (default)
# cert-manager — cert-manager issues the serving cert and its ca-injector
# populates caBundle (requires cert-manager installed;
# configure certManager.issuerRef below).
# manual — you pre-create the tls.secretName Secret (kubernetes.io/tls)
# and set webhook.caBundle (base64 PEM) yourself.
mode: self
# -- Name of the Secret holding tls.crt / tls.key (and, in self mode, ca.crt).
# In self mode the operator creates and owns it; in cert-manager mode
# cert-manager writes it; in manual mode YOU create it before install.
secretName: kopiur-webhook-tls
certManager:
# -- Use an existing Issuer/ClusterIssuer instead of the self-signed Issuer
# this chart creates. Only used when tls.mode is cert-manager. Leave name
# empty to use the chart-managed self-signed Issuer.
issuerRef:
name: ""
kind: Issuer # Issuer | ClusterIssuer
# -- Base64-encoded PEM CA bundle injected into the webhook configurations.
# Only used when tls.mode is manual; required there so the API server trusts
# the serving cert. Ignored in self and cert-manager modes (caBundle is
# populated by the operator or cert-manager's ca-injector respectively).
caBundle: ""
# =============================================================================
# Mover (per-Job image)
# =============================================================================
# The mover has no Deployment — the controller stamps one Job per
# Backup/Restore/Maintenance run, reading this image reference. Its security
# context and scheduling are controller-stamped, not chart values, so only the
# image is configurable here.
mover:
image:
# -- Full mover image repository (registry + path).
repository: ghcr.io/home-operations/kopiur-mover
# -- Defaults to .Chart.AppVersion when empty.
tag: ""
# -- Pin the mover image by digest so a re-pulled tag can never change what
# runs in a data-protection Job. STRONGLY RECOMMENDED in production.
digest: ""
# -- Pull policy used on the mover Job pods.
pullPolicy: IfNotPresent
See also¶
- Installation — quickstart, scope, webhook-TLS
--setrecipes, CRD lifecycle. - Movers, RBAC & credentials — what the mover Jobs need and how projection works.
- Observability — the full metric list, OTLP details, collector config.
- The chart's own
README.md— generated from the same values.