Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Documentation

universal-helm-chart is one Helm chart that covers the common Kubernetes workload shapes — Deployments, StatefulSets, Jobs, CronJobs — along with the networking, autoscaling, monitoring, secrets and policy resources that almost every release ends up needing. The goal is to keep one well-tested template in one place so individual services don’t each carry a pile of YAML.

These docs are versioned with the chart and never ship inside the OCI artifact (see .helmignore).

Where to start

You want to…Read
Understand what this chart does and why it’s shaped that way01-getting-started/
Copy a working example02-examples/
Look up a values.yaml field03-reference/
Hack on the chart itself04-contributing/
Read the design decision behind a feature05-adr/

Sections

  • 01-getting-started/ — concepts, install, quickstart.
  • 02-examples/ — self-contained scenarios. Each example is a folder you can copy verbatim. Every example carries its own values.yaml plus ready-to-apply manifests for Argo CD (single-source and multi-source sources[]), Flux (OCIRepository + HelmRelease) and plain helm CLI.
  • 03-reference/ — values.yaml reference, schema notes, version compatibility.
  • 04-contributing/ — local dev, testing, releases.
  • 05-adr/ — Architecture Decision Records. Numbered by importance: 001 is the most foundational decision, the tail covers narrower refinements.

Conventions

  • Top-level sections and concrete examples use numeric prefixes (01-, 02-, …) so file-system order matches reading order.
  • ADR files use a three-digit prefix (001-…).
  • Examples follow a fixed shape: README.md, values.yaml, argocd/, flux/, helm/. The kitchen-sink example (99-kitchen-sink/) intentionally exercises everything at once.
  • All documentation is in English.

Getting started

Three short reads, in order:

  1. 01-concepts.md — what the chart actually does for you. Killer features, design ideas, the four primitives you need to keep in your head.
  2. 02-installation.md — pulling the chart from the OCI registry, prerequisites, optional CRDs (Gateway API, ESO, KEDA, Prometheus Operator).
  3. 03-quickstart.md — a five-minute “hello world”: one Deployment + Service from the smallest possible values.yaml.

If you already know the chart and just want to copy something, jump to ../02-examples/.

Concepts

This page is the “why” of the chart — what it actually does for you that a hand-rolled helm create chart doesn’t, and the small set of ideas you need in your head to read any values.yaml.

For the full design rationale of any item below, follow the linked ADR.

What this chart is

One Helm chart that covers every common Kubernetes workload shape — and the supporting cast every release ends up needing. Deployment, StatefulSet, Job and CronJob, plus their Services, Ingresses, Gateway API routes, autoscalers, monitors, ExternalSecrets, NetworkPolicies, PodDisruptionBudgets, RBAC, sync-wave annotations and Image Updater configs.

It replaces the typical pattern of “one chart per service, all subtly different” with one well-tested template consumed by N services. Bug fixes and security defaults land once and propagate to every consumer on next upgrade. (ADR 001)

The four primitives

These four ideas explain almost every line of values.yaml. The rest is detail.

1. Workloads as keyed maps

deployments, statefulSets and jobGroups are maps, not lists. The map key is the workload’s logical name and becomes part of the rendered resource name (<release-fullname>-<key>). One release can ship many workloads of the same kind without template duplication, and overlay values can target a single entry without rewriting the rest.

deployments:
  api:
    image:
      repository: my/api
      tag: v1
    service:
      ports:
        http:
          port: 80
          targetPort: 8080
  worker:
    image:
      repository: my/worker
      tag: v1
    service:
      enabled: false

→ two Deployments, one Service for the API, one shared release. (ADR 002)

2. jobGroups for both Job and CronJob

A jobGroup has a kind (Job | CronJob) and a jobs: map. Per-job fields override group-level fields with shape-specific rules — scalars take the job’s value or fall back to the group’s; lists concat group-then-job; nested maps (env, image, resources, securityContext, inherit, hooks.*, etc.) deep-merge with the job winning on key collision; name-keyed maps (volumes, volumeMounts) replace whole entries by name. The same shape covers schema migrations, nightly cleanups, smoke checks. (ADR 005)

Crucially: every Job’s name carries an 8-char hash of its rendered spec. Same spec → same name → Argo CD/Flux see “no change” and skip. Spec changes → new name → new Job runs once. Old Jobs are GC’d by ttlSecondsAfterFinished. Migrations never re-run on every sync. (ADR 012)

3. integrations

Every external system — Argo CD, Stakater Reloader, External Secrets Operator, Prometheus / VictoriaMetrics Operators, KEDA — lives under integrations.* with an enabled toggle and a config block. One block to skim, one place to add the next integration. (ADR 006)

4. Layered inheritance with explicit override

Environment variables, ConfigMap mounts, Secret mounts, labels, annotations, scheduling parameters — all cascade through four layers:

global  →  root  →  workload  →  container

Maps replace per key, lists concat, inherit.* flags opt out per-workload. A platform team can ship OTEL_EXPORTER_OTLP_ENDPOINT in global.env once and have every workload pick it up; a single noisy worker can opt out without touching ancestor configuration. (ADR 003)

Killer features

Things this chart gives you that you’d otherwise build yourself.

One chart, every common workload

Deployment, StatefulSet, Job, CronJob — all in one chart, all sharing labels / inheritance / monitoring / secrets / sync-waves. No subcharts. No “but this is the API chart, the migrations chart is over there.” (ADR 001)

Multiple workloads per release

A real microservice often ships as api + worker + cron + migrate — four resources that share an image, env, secrets and labels. Declare them once, in one values file, and the chart renders four resources with the right cross-references. (ADR 002)

Job idempotency under GitOps

Argo CD on a 1-minute sync interval doesn’t re-run your migrations. Hash-suffixed Job names make the Job’s identity a function of its spec. Image bump → new Job. Spec unchanged → no Job runs. With Argo CD Image Updater + update-strategy: digest, even mutable tags (dev, main) get re-runs on actual image change, not on tag-name unchanged. (ADR 012)

Multi-provider monitoring

A three-axis matrix: provider (Prometheus | VictoriaMetrics) × discovery (CRD | annotations) × target (service | pod). Eight combinations behind one mental model. Chart works in clusters running Prometheus Operator, VictoriaMetrics Operator, both, or neither. Switching providers is one values key. (ADR 008)

Auto-exposed metrics port

integrations.monitoring.defaults.exposeService.enabled: true — the chart appends a metrics port to every Service and pod template, fixed name, last position. Workloads with no main Service get a dedicated metrics-only Service. No more boilerplate port pairs across N workloads. (ADR 016)

Dual networking stack

Ingress and Gateway API co-exist. Pick whichever fits the controller you have. Each has both a singleton shape (one routing object per release, the common case) and a multi-resource map (one per controller / parent / hostname). Per-workload route shorthand merges into the singleton with explicit priority for ordering. Cross-namespace ReferenceGrant is a one-block declaration. (ADR 009)

Argo CD sync-waves out of the box

Clean first-time syncs in Argo CD: ServiceAccount → SecretStore → ExternalSecret/ConfigMap → Service → workloads → derived (HPA/VPA/monitors) → routes. No flickering “Degraded” on a fresh install. Override per kind, opt-out per kind, disable wholesale — your call. (ADR 010)

External Secrets Operator with smart disambiguation

SecretStore and ExternalSecret resources, both data (map keyed by secretKey) and dataFrom (list, bulk extraction). Chart-owned ConfigMaps and Secrets win over external references on name collisions — no silent shadowing surprises. ESO target template annotations propagate to the generated Secret so Reloader picks up rotations. (ADR 015)

Reloader integration

One toggle injects reloader.stakater.com/auto: "true" onto Deployments, StatefulSets, ConfigMaps and ExternalSecret target templates. Secret rotation in your Vault store → ESO updates the K8s Secret → Reloader bounces the workload. End to end. (ADR 017)

Argo CD Image Updater

Shorthand mode tracks an image with update-strategy: digest, writing the new tag back via Argo CD parameter overrides. Mutable tags become safe — the digest is what changes, not the tag name. Combined with hash-suffixed Jobs, migrations re-run on actual image change. (See integrations.argocd.imageUpdater in values.yaml.)

HPA / KEDA / VPA with mutual-exclusion guards

HPA on metrics, KEDA on events, VPA on resource requests. The chart fail-fasts on HPA + KEDA on the same workload (they’d both fight for replicas). VPA is orthogonal. spec.replicas is omitted from the rendered manifest when an HPA-class scaler is on, so Helm doesn’t bounce the count on every apply. (ADR 007)

Standard-wins labels with invariant selectors

Set commonLabels for fleet-wide labels (cost-center, team, environment) without ever risking a selector break. The chart-managed app.kubernetes.io/{name,instance} are emitted on workload selectors regardless of any toggle — the API server forbids selector mutation, and we make sure you can’t accidentally mutate them. The other app.kubernetes.io/* labels follow your toggles, on singletons. (ADR 011)

Schema-driven validation

values.schema.json catches typos at install time, not at runtime. additionalProperties: false on closed objects, conditional validation via if/then, regex on map keys. IDEs with yaml-language-server light up values.yaml with autocomplete and inline errors. Templates render only — no regexMatch / kindIs / required sprinkled across helpers. (ADR 013)

Maps over lists, deterministic ordering

The whole values API leans on maps with a natural key. Overlay merging targets one entry; duplicates are caught by the YAML parser; per-entry disabling is enabled: false. Iteration is alphabetical except where convention says otherwise (http first, metrics last for ports; explicit priority for Gateway API rules). helm template on identical inputs produces byte-identical output. (ADR 004, ADR 014)

Sane secure defaults

Out of the box: runAsNonRoot: true, runAsUser: 1000, seccompProfile: RuntimeDefault, readOnlyRootFilesystem: true, capabilities.drop: [ALL], automountServiceAccountToken: false, terminationGracePeriodSeconds: 30. Override per workload. Pod Security Standards “Restricted” level without thinking about it.

Tested

The chart ships with a comprehensive helm-unittest suite that runs on every PR. Templates are dense; the test suite catches drift before it lands on consumers. Fixtures double as executable documentation for non-trivial scenarios. (ADR 018)

What this chart is not

  • A platform. It renders Kubernetes resources from values. It does not deploy itself, manage namespaces, or own cluster-wide policy.
  • A subchart factory. No nested subcharts of its own — composition is by values.yaml, not by chart hierarchy. That said, this chart works excellently as a subchart itself: drop it under dependencies: in your umbrella chart and the parent’s global.env, global.image, etc. propagate into every workload via the four-layer cascade. One umbrella chart consuming N instances of this chart is a first-class use case.
  • A DaemonSet / ReplicaSet shop — for now. Out of scope in the current release (ADR 001). DaemonSet support may land later if there is demand; ReplicaSet is unlikely (Deployment is the right abstraction in almost every case).
  • Opinionated about your CI/CD tool. Examples cover Argo CD (single + multi-source), Flux (OCIRepository + HelmRelease) and the plain helm CLI. Pick whichever; the chart doesn’t care.

Where to go next

Installation

OCI registry

The chart is published to GHCR as an OCI artifact:

oci://ghcr.io/purisev/universal-helm-chart

There is no Helm “repo add” step — Helm 3.8+ pulls OCI charts directly:

helm install my-app oci://ghcr.io/purisev/universal-helm-chart \
  --version 3.3.0 \
  -f values.yaml

Optional CRDs

The chart only renders a resource if you opt in via values.yaml. The following CRDs must be installed in the cluster if and only if you use the matching feature:

FeatureCRDs
integrations.eso (External Secrets)external-secrets.io
integrations.monitoring with provider=prometheus and discovery=crdmonitoring.coreos.com (Prometheus Operator)
integrations.monitoring with provider=victoriametrics and discovery=crdoperator.victoriametrics.com
integrations.keda autoscalerskeda.sh
Gateway API routes (httpRoute / grpcRoute / tlsRoute / referenceGrant)gateway.networking.k8s.io
verticalPodAutoscalerautoscaling.k8s.io (VPA)
integrations.argocd.imageUpdaterargoproj.io

See 03-reference/03-compatibility.md for tested CRD versions.

Quickstart

The minimal viable values.yaml:

deployments:
  api:
    image:
      repository: nginx
      tag: "1.27"
    service:
      ports:
        http:
          port: 80

Install:

helm install demo oci://ghcr.io/purisev/universal-helm-chart --version 3.3.0 -f values.yaml

A complete copy-and-deploy version of this scenario, including Argo CD and Flux manifests, lives at ../02-examples/01-minimal/.

Examples

Self-contained scenarios. Each folder is a copy-and-deploy unit. The shape is identical everywhere:

NN-name/
├── README.md                       # what this shows; delta vs. previous example
├── values.yaml                     # the chart values for this scenario
├── argocd/
│   ├── application.yaml            # single-source: chart from oci://ghcr.io/purisev
│   └── application-multisource.yaml    # spec.sources[]: chart from OCI + values from a git repo via $values
├── flux/
│   ├── ocirepository.yaml          # FluxCD OCIRepository pointing at the OCI chart
│   └── helmrelease.yaml            # HelmRelease consuming the OCIRepository
└── helm/
    └── install.sh                  # plain helm install / upgrade --install command

Numbering encodes complexity: 01 is the smallest possible deploy, 99 shows everything at once.

Note: 09-multi-env-overrides/ deliberately breaks the fixed shape — it ships multiple values-<env>.yaml files plus environment-specific Argo CD / Flux manifests. See its own README.

Catalog

#FolderDemonstrates
0101-minimal/Deployment + Service, smallest possible values
0202-web-app-ingress/Deployment + Service + Ingress + HPA + ServiceMonitor
0303-statefulset-pvc/3-replica StatefulSet with per-pod PVC, headless Service, PDB
0404-gateway-api/HTTPRoute with per-workload route shorthand + cross-namespace ReferenceGrant
0505-cronjobs/jobGroups — Job (PreSync hook, tasks-mode) + CronJob (two scheduled jobs)
0606-monitoring/ServiceMonitor + PodMonitor + annotations-mode + per-workload provider override (VM)
0707-external-secrets/ESO SecretStore (Vault) + ExternalSecret (data and dataFrom) + Reloader
0808-keda-event-driven/KEDA ScaledObject (Kafka lag trigger) + VPA Initial mode (orthogonal)
0909-multi-env-overrides/Base values + per-env overlays + Argo CD ApplicationSet + Flux valuesFrom
1010-init-containers/Per-workload initContainers map — wait-for-db + schema migration before the main container starts
9999-kitchen-sink/Everything-on showcase

Picking an example

  • Brand new to the chart → 01-minimal.
  • Web service that needs to be reachable from outside the cluster → 02-web-app-ingress.
  • Stateful workload with persistent storage → 03-statefulset-pvc.
  • Migrating from Ingress to Gateway API → 04-gateway-api.
  • Schema migrations or scheduled tasks → 05-cronjobs.
  • Wiring up scrape targets → 06-monitoring.
  • Pulling secrets from Vault / AWS / GCP → 07-external-secrets.
  • Event-driven worker scaling → 08-keda-event-driven.
  • Same app in dev / staging / prod → 09-multi-env-overrides.
  • Init containers for wait-for-X / one-shot bootstrap before the main app → 10-init-containers.
  • All the bells and whistles → 99-kitchen-sink.

01 — minimal

The smallest possible deploy: one Deployment running an nginx image, exposing port 80 via a ClusterIP Service. No Ingress, no monitoring, no secrets, no autoscaling.

What this shows

  • One workload declared under deployments.web.
  • The single-port Service shorthand (service.port / service.targetPort rather than the service.ports map).
  • The opt-in default for everything else: monitoring off, autoscaling off, RBAC off.

Files

FilePurpose
values.yamlChart values for this scenario.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[] — chart from OCI, values from a git repo via $values.
flux/ocirepository.yamlFlux OCIRepository pointing at the OCI chart.
flux/helmrelease.yamlFlux HelmRelease consuming the OCIRepository.
helm/install.shPlain helm upgrade --install command.

Try it

Pick any one of:

  • Helm CLI: bash helm/install.sh
  • Argo CD: kubectl apply -f argocd/application.yaml -n argocd
  • Flux: kubectl apply -f flux/

02 — web app with Ingress and HPA

A web service reachable from outside the cluster, scaling on CPU.

What this shows

  • One Deployment with a multi-port Service (http + metrics).
  • Singleton ingress block with one host and one TLS secret.
  • HPA on CPU.
  • Monitoring via ServiceMonitor (Prometheus Operator), targeting the metrics port by name.
  • A pod-level securityContext and resource requests / limits.

Delta from 01-minimal

AddedWhat changed
service.ports (map)Replaces single-port service.port shorthand to expose two ports.
metrics.enabled: trueOpt-in to monitoring (defaults are off).
ingressRoutes web.example.com to the Service. Its serviceName is a plain string, so it has to name what the chart renders.
fullnameOverridePins the prefix on every resource name to webapp, making the Service webapp-web instead of webapp-universal-helm-chart-web — that is the name the Ingress backend spells out.
hpaAuto-scales on CPU; replicaCount is omitted from the rendered Deployment because HPA is on (see ADR 007).
integrations.monitoring.defaults.enabled: trueTurns the default scrape on for every workload (per-workload metrics.enabled still acts as opt-out).

Files

FilePurpose
values.yamlChart values for this scenario.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install command.

Try it

# Helm
bash helm/install.sh

# Argo CD
kubectl apply -f argocd/application.yaml -n argocd

# Flux
kubectl apply -f flux/

Prerequisites

  • An Ingress controller (e.g. nginx, traefik) installed in the cluster. Adjust ingressClassName.
  • Prometheus Operator CRDs (monitoring.coreos.com) installed if you want the ServiceMonitor to actually be picked up. Without them the chart still renders but the resource has no consumer.
  • For TLS to work, a Secret named web-example-tls with cert/key, or a cert-manager Certificate issuing into it.

03 — StatefulSet with PVC and headless Service

A 3-replica PostgreSQL-style StatefulSet with stable storage and stable network identity. The headless Service exposes each pod under a predictable DNS name (<release>-db-0.<release>-db.<ns>.svc).

What this shows

  • A statefulSets.db workload — replaces deployments.<n> for ordered, stateful pods.
  • volumeClaimTemplates — a list of embedded PVCs, one entry named data, so every pod gets its own PVC, retained across restarts and scale-up.
  • service.clusterIP: None — headless Service required by StatefulSet for stable per-pod DNS.
  • replicaCount: 3 with podManagementPolicy: OrderedReady (chart default).
  • envSecrets referencing an externally-managed Secret for POSTGRES_PASSWORD (no ESO here — see 07-external-secrets/ for that).
  • Per-pod volumeMounts map keyed by mount name, referencing the volumeClaimTemplates entry by its metadata.name.

Delta from 02-web-app-ingress

ChangedWhat and why
deployments → statefulSetsStatefulSet, not Deployment — needed for stable storage + stable DNS.
volumeClaimTemplatesPer-pod PVC; volumes (the per-pod ephemeral kind) wouldn’t survive pod replacement.
service.clusterIP: NoneHeadless Service so each pod gets <pod>.<service>.<ns>.svc DNS.
service.ports.postgresSingle non-http port — port ordering rule still puts it first since “http” is absent (alphabetical fallback).
No Ingress, no HPAStateful workloads aren’t horizontally autoscaled in this template (use a connection pooler or read-replicas pattern instead).
Pre-existing Secretdb-credentials Secret must exist in the namespace before install (or come from a different release). For an ESO-managed flow, see ../07-external-secrets/.

Files

FilePurpose
values.yamlChart values.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install.

Try it

# First create the password Secret (this example doesn't manage it)
kubectl create namespace stateful
kubectl create secret generic db-credentials \
  --from-literal=POSTGRES_PASSWORD='changeme' \
  --from-literal=POSTGRES_USER='app' \
  -n stateful

# Then install via any of these
bash helm/install.sh
kubectl apply -f argocd/application.yaml -n argocd
kubectl apply -f flux/

Prerequisites

  • A default StorageClass in the cluster, or set storageClassName in the data entry’s spec for your provisioner.
  • The db-credentials Secret pre-existing in the target namespace.

04 — Gateway API with cross-namespace ReferenceGrant

Two Deployments (api, web) routed by a singleton HTTPRoute. The Gateway lives in a different namespace (gateway-system) — a ReferenceGrant permits the cross-namespace backend reference.

What this shows

  • Singleton httpRoute declaration with parentRefs pointing to a Gateway in gateway-system.
  • Per-workload route shorthand: each workload contributes rules into the merged HTTPRoute via deployments.<n>.httpRoute.rules. Order is controlled by priority (lower wins; ties alphabetical).
  • serviceName and servicePort default to the workload’s own Service — no manual cross-reference.
  • referenceGrant allows the gateway-system namespace to reference Services in this release’s namespace. Without it, Gateway API rejects the cross-namespace backend ref.
  • Path-based routing: /api/* → api workload, everything else → web workload.
  • Rule-level name: optional string (lowercase RFC 1123 label) identifying a rule in Gateway API status, traces, and access logs. Requires Gateway API v1.4+ Standard or v1.2+ Experimental CRDs.
  • Rule-level timeouts: request caps the full round-trip budget; backendRequest caps backend processing time (must be ≤ request). Both accept Gateway API Duration strings (30s, 1h30m, 100ms). Standard since Gateway API v1.2.
  • Rule-level retry: attempts sets the attempt limit; codes lists HTTP status codes 400–599 that trigger a retry (implementations MUST support 500/502/503/504); backoff sets the minimum delay between attempts. Requires Gateway API Experimental channel CRDs — Standard CRDs reject the field.

Delta from 02-web-app-ingress

ChangedWhat and why
Ingress → HTTPRouteModern Gateway API instead of legacy Ingress. Same external behaviour, more expressive routing.
Per-workload routesThe route is split per backend — each workload owns its match rules — and merged into one resource.
referenceGrant blockRequired for cross-namespace Service references in Gateway API.
Multiple workloadsTwo Deployments serving different paths under one host.
Removed cert-manager annotationTLS is handled at the Gateway listener level, outside this chart.
name, timeouts, retry on api ruleShows rule identification, timeout budgets, and retry policy — absent from the web catch-all to contrast optional vs. omitted.

Files

FilePurpose
values.yamlChart values.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install.

Try it

bash helm/install.sh
kubectl apply -f argocd/application.yaml -n argocd
kubectl apply -f flux/

Prerequisites

  • Gateway API CRDs (gateway.networking.k8s.io) installed.
  • A Gateway named external running in the gateway-system namespace, with a listener (e.g. https) accepting routes from this namespace’s selector.
  • A Gateway controller compatible with Gateway API v1 (Cilium, Envoy Gateway, Istio, Traefik, Kong).

05 — jobGroups (Job + CronJob)

Two jobGroups in one release:

  • migrations — kind: Job, runs as a PreSync hook on every Argo CD sync. Tasks-mode: two ordered initContainers (01-structure then 02-seed) inside one Job.
  • nightly — kind: CronJob with two scheduled jobs sharing image and env: cleanup at 02:00 UTC and report at 06:00 UTC.

What this shows

  • Group-level config (image, envSecrets, hooks) cascades into every job in jobs:. Per-job fields override.
  • Tasks mode: a job whose work is split across multiple ordered initContainers, then a tiny “completed” container marks the Job successful. Useful for migration → seed pipelines where step ordering matters. Kubernetes runs initContainers sequentially in their declared order, and the chart emits the tasks map sorted alphabetically — so when ordering matters, prefix task names with 01-, 02-, 03- etc. to make the run order explicit. (See ADR 014.)
  • Single-container mode (command / args directly on the job) for the CronJob members.
  • Hash-suffixed Job names (<release>-migrations-schema-<sha8>) — the Job re-runs only when its rendered spec changes (image bump, env change, command edit). Argo CD on a 1-minute sync interval is harmless. (ADR 012)
  • Argo CD hook annotations on the migrations group → Argo CD treats those Jobs as PreSync hooks; the user-visible workloads continue applying once the hook completes.
  • CronJob fields (schedule, timeZone, concurrencyPolicy, history limits) live per job — different schedules per job in the same group.

Delta from 01-minimal

AddedWhat
jobGroups.migrationsPre-sync schema migration via tasks-mode initContainers.
jobGroups.nightlyTwo CronJobs sharing image/env, different schedules.
Argo CD hooksMigrations group runs as PreSync with syncWave: -2.
External Secret referencedb-credentials Secret carries DB_PASSWORD for migrations.

(There is no Deployment in this example — purely batch workloads.)

Files

FilePurpose
values.yamlChart values.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install.

Try it

# Pre-create the credentials Secret
kubectl create namespace batch
kubectl create secret generic db-credentials \
  --from-literal=DB_PASSWORD='changeme' \
  -n batch

bash helm/install.sh

06 — Monitoring (Prometheus + VictoriaMetrics, Service + Pod targets)

Three workloads exercising the monitoring matrix:

  • api — Prometheus ServiceMonitor (CRD discovery + service target).
  • worker — Prometheus PodMonitor (CRD discovery + pod target). No Service.
  • legacy — annotations-mode (prometheus.io/scrape etc.) for clusters that haven’t installed Prometheus Operator CRDs.

Plus chart-level exposeService.enabled: true so metrics ports are auto-emitted on Services that don’t declare one.

What this shows

  • integrations.monitoring.defaults sets Prometheus + CRD + service as the cluster default.
  • Per-workload metrics.* overrides every axis individually:
    • api accepts the defaults (single value: metrics.enabled: true).
    • worker switches type: pod to emit a PodMonitor instead.
    • legacy switches discovery: annotations to skip the CRD entirely.
  • Auto-exposed metrics port on api (exposeService.enabled: true with no explicit service.ports.metrics).
  • Metrics-only Service for worker because its service.enabled: false — the chart still emits a <release>-worker-metrics Service so the PodMonitor’s port name resolves cleanly.
  • A victoriametrics-flavoured workload showing the per-workload provider override.

Delta from 02-web-app-ingress

ChangedWhat
integrations.monitoring.defaultsSet chart-level monitoring defaults explicitly.
Three workloadsOne per discovery/target combination.
metrics.exposeService.enabledEnabled chart-wide so the metrics port lands automatically where applicable.
metrics.type: pod on workerPodMonitor instead of ServiceMonitor.
metrics.discovery: annotations on legacyBare Prometheus annotations on the Service.
metrics.provider: victoriametrics on vm-flavouredPer-workload provider override (emits VMServiceScrape instead of ServiceMonitor).

Files

FilePurpose
values.yamlChart values.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install.

Try it

bash helm/install.sh
kubectl apply -f argocd/application.yaml -n argocd
kubectl apply -f flux/

Prerequisites

  • Prometheus Operator CRDs (monitoring.coreos.com) installed for ServiceMonitor / PodMonitor to materialise.
  • VictoriaMetrics Operator CRDs (operator.victoriametrics.com) installed for the vm-flavoured workload’s VMServiceScrape. Remove the vm-flavoured workload if VM Operator isn’t present.
  • Prometheus / vmagent configured to discover the right CRDs / pick up annotations.

07 — External Secrets Operator + Reloader

A web app whose database password and API tokens are synced from HashiCorp Vault by ESO. When Vault rotates the secret, ESO updates the Kubernetes Secret, and Reloader bounces the Deployment to pick up the change.

What this shows

  • integrations.eso.secretStores.vault — a SecretStore resource targeting Vault via JWT auth.
  • integrations.eso.externalSecrets.app-secrets — an ExternalSecret with explicit per-key data mapping. Each entry maps a remote key + property to a field in the resulting K8s Secret.
  • integrations.eso.externalSecrets.bulk-config — a second ExternalSecret using dataFrom (bulk extraction): every field in the remote secret lands in the K8s Secret.
  • integrations.stakater.reloader.enabled: true — when ESO writes a new K8s Secret, Reloader auto-restarts the Deployment.
  • The Deployment consumes both secrets via envSecrets — the standard chart pattern.
  • serviceAccount.create: true — required for Vault JWT auth (the SA token is what Vault validates).

Delta from 02-web-app-ingress

ChangedWhat
integrations.esoNew: secretStores + externalSecrets blocks.
integrations.stakater.reloader.enabled: trueAuto-restart on Secret/CM change.
serviceAccount.create: trueRequired for ESO JWT auth via SA token.
envSecretsNow references the ESO-generated Secrets, not externally-created ones.

Files

FilePurpose
values.yamlChart values.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install.

Try it

bash helm/install.sh
kubectl apply -f argocd/application.yaml -n argocd
kubectl apply -f flux/

Prerequisites

  • External Secrets Operator installed (external-secrets.io CRDs).
  • Stakater Reloader installed (or remove integrations.stakater.reloader from the values).
  • A reachable Vault server with JWT auth method enabled, role eso-app allowed, and the secrets present at secret/data/app/....
  • The app-vault SecretStore JWT mount path matches Vault’s auth/jwt configuration.

08 — KEDA event-driven autoscaling

A Kafka consumer that scales 1 → 30 replicas based on consumer-group lag, plus VPA in Initial mode for right-sized memory requests on first pod start.

What this shows

  • keda.enabled: true on the worker, with a kafka trigger keyed on consumer-group lag.
  • minReplicas: 1, not 0 — this worker should never have a gap in consumption. Set minReplicas: 0 instead when the workload can tolerate sitting idle (e.g. a cron trigger active only during a scheduled window); KEDA scales it down to zero once every trigger goes inactive, and back up once one fires again.
  • hpa.enabled: false (default) — KEDA itself manages an internal HPA; mixing both would be rejected by the chart’s mutual-exclusion guard.
  • keda.advanced — passed into the ScaledObject verbatim. horizontalPodAutoscalerConfig.behavior shapes the HPA that KEDA creates: one pod a minute up instead of the HPA default of doubling every 15 seconds, and 15 minutes of quiet before a replica is given back. Kafka lag is spiky, and the default behaviour overshoots a queue that drains fast. restoreToOriginalReplicaCount: true puts the Deployment back on its pre-KEDA replica count if the ScaledObject is ever deleted. The chart validates the block as an object and nothing else, so any field in the ScaledObject spec works here.
  • verticalPodAutoscaler.enabled: true with updateMode: Initial — VPA sets the resource request the first time each pod is created. Works alongside KEDA because VPA touches requests, KEDA touches replicas. (ADR 007)
  • service.enabled: false — pure consumer, no inbound traffic.
  • metrics.type: pod + integrations.monitoring.defaults.exposeService.enabled: true → a metrics-only Service <release>-worker-metrics is auto-emitted, and a PodMonitor scrapes the worker pods directly.
  • The Deployment manifest will have no spec.replicas because an HPA-class scaler is on — so Helm upgrade doesn’t bounce the count back to a static value on every sync.

Delta from 02-web-app-ingress

ChangedWhat
HPA → KEDAEvent-driven scaling; HPA off, KEDA on.
Scaling behaviourkeda.advanced.horizontalPodAutoscalerConfig.behavior caps the ramp at one pod a minute.
Added VPAOrthogonal, recommends/sets memory requests on initial pod create.
Service offWorker has no listener; metrics-only Service auto-emitted instead.
metrics.type: podPodMonitor instead of ServiceMonitor.
Removed IngressNo external traffic.

Files

FilePurpose
values.yamlChart values.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install.

Try it

bash helm/install.sh
kubectl apply -f argocd/application.yaml -n argocd
kubectl apply -f flux/

Prerequisites

  • KEDA installed in the cluster (keda.sh CRDs).
  • VPA installed (autoscaling.k8s.io CRDs).
  • A Kafka cluster reachable at kafka.kafka:9092 with topic orders and consumer group orders-worker. Adjust to your environment.
  • Prometheus Operator CRDs for the PodMonitor (or remove the metrics: block).

09 — Base values + per-env overrides (dev / staging / prod)

Note — different shape than other examples. Every other folder under docs/02-examples/ ships a single values.yaml and follows the fixed NN-name/{values.yaml,argocd/,flux/,helm/} layout. This example deliberately departs from that — there is no values.yaml. Tooling that iterates docs/02-examples/*/values.yaml will skip this folder, which is intentional. Use values-base.yaml together with one of the values-<env>.yaml overlays.

One chart, one app — three environments. The values are split into a values-base.yaml carrying the invariant configuration and three thin values-<env>.yaml overlays that adjust replica counts, resource sizing, image tags and a few env vars.

This shows how the chart’s keyed-map structure (ADR 002) and global.env cascade (ADR 003) make per-environment overlays minimal — typically 10-20 lines each.

Files (different shape than other examples)

FilePurpose
values-base.yamlCommon configuration applied to every environment.
values-dev.yamlDev overrides: 1 replica, smaller resources, dev image tag.
values-staging.yamlStaging overrides: 2 replicas, prod-ish sizing, staging hostname.
values-prod.yamlProd overrides: HPA on, real resources, real hostname, monitoring on.
argocd/application-prod.yamlArgo CD Application for prod, multi-source: chart + base + prod overlay.
argocd/applicationset.yamlOne Argo CD ApplicationSet rendering one Application per environment from a list generator.
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease-prod.yamlFlux HelmRelease for prod, with valuesFrom referencing a ConfigMap + inline overlay.
helm/install-dev.shPlain helm upgrade --install for dev (-f values-base.yaml -f values-dev.yaml).

What this shows

  • Two-file values pattern: pass values-base.yaml and values-<env>.yaml to Helm via repeated -f. Later files win on key collision.
  • Per-env fullnameOverride: each overlay pins its own resource-name prefix (orders-dev / orders-staging / orders-prod), so the prod Ingress backend can name orders-prod-api without depending on the release name Argo CD or Flux passes.
  • Argo CD multi-source with multiple valueFiles: the chart source lists both files; Argo CD merges them in order before running Helm.
  • Argo CD ApplicationSet: a list generator declares the envs once; the template stamps out an Application per env. Avoids hand-maintaining three near-identical Application CRs.
  • Flux valuesFrom: prod values can also live in a ConfigMap (managed separately) — useful when ops change values out-of-band of git.

Try it (dev only — adjust for other envs)

# Helm
bash helm/install-dev.sh

# Argo CD (prod)
kubectl apply -f argocd/application-prod.yaml -n argocd

# Argo CD (all three envs at once)
kubectl apply -f argocd/applicationset.yaml -n argocd

# Flux (prod)
kubectl apply -f flux/

10 — init containers

A Deployment with two init containers that run before the main nginx container starts. The first blocks pod startup until the database is reachable; the second applies a schema migration. Both run sequentially, in the order their map keys sort alphabetically.

What this shows

  • Per-workload initContainers declared as a map keyed by container name.
  • The 01-, 02- prefix convention for run order — same idea as jobGroups[*].tasks (Kubernetes runs init containers sequentially in declared order; the chart sorts the map alphabetically before emitting).
  • Per-init container image override (the migration init uses a different image from the main container).
  • envSecrets on an init container — root envSecrets are merged in automatically; the per-init list adds workload-specific Secrets on top.

Delta vs. previous examples

01-minimal/ shows the simplest case: one container, one Service. This example adds two init containers ahead of the main container without changing anything else. The same initContainers map is also accepted under statefulSets.<name> with identical semantics.

Files

FilePurpose
values.yamlChart values for this scenario.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[] — chart from OCI, values from a git repo via $values.
flux/ocirepository.yamlFlux OCIRepository pointing at the OCI chart.
flux/helmrelease.yamlFlux HelmRelease consuming the OCIRepository.
helm/install.shPlain helm upgrade --install command.

Try it

Pick any one of:

  • Helm CLI: bash helm/install.sh
  • Argo CD: kubectl apply -f argocd/application.yaml -n argocd
  • Flux: kubectl apply -f flux/

Heads-up

  • Reloader does not re-run init containers on config changes. Init containers run only on Pod creation. When a watched ConfigMap or Secret changes, Reloader rolls the workload — a fresh Pod starts, and the init containers run again as part of that fresh start. If your migration is idempotent (and it should be), this is exactly what you want.
  • volumeMounts.<initContainerName> at root level is NOT inherited. Init containers behave like sidecars here — declare any mounts they need inline under deployments.<name>.initContainers.<initName>.volumeMounts. Auto-mounted ConfigMaps (those declared under configMaps.<name> with a mountPath) DO mount into init containers, unless disabled via inherit.configMapMount.

99 — kitchen sink

Everything-on showcase. Don’t deploy this verbatim — it exists to demonstrate the chart’s full surface in one place. Use it as a reading reference; copy individual sections into a real values.yaml.

What this shows

  • Two Deployments (api, worker) with sidecars and per-workload inherit opt-outs.
  • One StatefulSet (db) with a volumeClaimTemplate and headless Service.
  • Two jobGroups:
    • migrations — pre-install Job hook, tasks-mode initContainers, hashed-name idempotency.
    • nightly — CronJob.
  • Service per Deployment with multi-port (http, grpc, admin, metrics) — port ordering exercises ADR 014.
  • Singleton httpRoute with parent Gateway + per-workload route shorthand and priority.
  • Cross-namespace referenceGrant.
  • Multi-Ingress map (internal + external).
  • HPA on the API; KEDA ScaledObject on the worker (event-driven on a Kafka lag scaler); VPA on the database.
  • integrations.monitoring with VictoriaMetrics provider, CRD discovery, plus an annotation-mode override for one workload.
  • ESO SecretStore (Vault) and two ExternalSecrets — one with explicit data, one with dataFrom.
  • Stakater Reloader on with the default auto strategy.
  • Argo CD ImageUpdater in shorthand mode tracking the API image with update-strategy: digest.
  • commonLabels, custom podSecurityContext, automountServiceAccountToken: true for the API (RBAC-bound), NetworkPolicy, PodDisruptionBudget, topologySpreadConstraints.

Delta from 02-web-app-ingress

This example doesn’t follow the +1 progression — it’s a leap to “everything”. Read the values file with one ADR open per section:

SectionRead first
deployments / statefulSetsADR 002, ADR 003
jobGroupsADR 005, ADR 012
integrations.argocd.syncWaves (default)ADR 010
integrations.monitoringADR 008, ADR 016
integrations.esoADR 015
integrations.stakater.reloaderADR 017
httpRoute + referenceGrant + ingressesADR 009
hpa / keda / verticalPodAutoscalerADR 007

Files

FilePurpose
values.yamlChart values for this scenario.
argocd/application.yamlArgo CD Application, single source.
argocd/application-multisource.yamlArgo CD Application with spec.sources[].
flux/ocirepository.yamlFlux OCIRepository.
flux/helmrelease.yamlFlux HelmRelease.
helm/install.shPlain helm upgrade --install (with a --dry-run flag enabled by default — copy and remove if you really want to install).

Prerequisites (CRDs)

Almost every CRD-bearing integration is touched. Don’t apply this in a cluster missing any of:

  • Gateway API (gateway.networking.k8s.io)
  • Prometheus or VictoriaMetrics Operator (depending on which provider you keep enabled)
  • External Secrets Operator (external-secrets.io)
  • KEDA (keda.sh)
  • VPA (autoscaling.k8s.io)
  • Argo CD Image Updater
  • Stakater Reloader

Reference

The authoritative per-field source is values.yaml (heavily annotated) and values.schema.json (machine-checkable shape). The pages here add cross-cutting context that those files don’t aggregate.

  • 01-values.md — topical index of every top-level key plus the cross-cutting rules (env merge, label precedence, port ordering, jobGroups merge, autoscaler exclusion, chart-owned vs external resolution).
  • 02-schema.md — what the schema does and doesn’t catch, schema layout, and IDE setup for autocomplete and inline validation (yaml-language-server / Red Hat YAML, JetBrains, helm lint, ajv-cli).
  • 03-compatibility.md — Helm version, Kubernetes APIs the chart renders, and the CRD groups/versions for each optional integration.
  • 04-migration.md — values-file edits required when upgrading between release lines.

Values reference

values.yaml at the chart root carries machine-readable defaults only — no inline comments. This page is the annotated reference: a topical index of every top-level key grouped by feature, with cross-links to the relevant ADR and example folder, plus a cross-cutting rules cheatsheet for behaviours that span multiple keys.

For the schema’s machine-checkable shape, see 02-schema.md. For supported Kubernetes / CRD versions, see 03-compatibility.md. For a complete working example with every chart feature enabled (passes helm template), see values.yaml.example at the chart root.

Topical index

TopicTop-level keysRead more
Identity & metadatacommonLabels, commonAnnotations, labels.standard, nameOverride, fullnameOverride, naming.omitWorkloadSuffixADR 011 · ADR 021
Environment & configglobal.env, env, envSecrets, envConfigMaps, configMaps, podAnnotations, jobPodAnnotationsADR 003 · ADR 015
Image & pullsimage, imagePullSecrets, jobCompletionImage02-examples/01-minimal/
Deployments / StatefulSetsdeployments, statefulSets, strategy, statefulSetUpdateStrategy, revisionHistoryLimit, progressDeadlineSeconds, minReadySecondsADR 002 · 02-examples/03-statefulset-pvc/
Per-pod containersper-workload initContainers, sidecarsADR 002 · ADR 014 · 02-examples/10-init-containers/
Job groupsjobGroupsADR 005 · ADR 012 · 02-examples/05-cronjobs/
Networking — Ingressingress (hosts, tls as native k8s list, defaultBackend), ingressesADR 009 · 02-examples/02-web-app-ingress/
Networking — Gateway APIhttpRoute, httpRoutes, grpcRoute, grpcRoutes, tlsRoute, tlsRoutes, referenceGrant, referenceGrantsADR 009 · 02-examples/04-gateway-api/
Autoscalingper-workload hpa, keda, verticalPodAutoscalerADR 007 · 02-examples/08-keda-event-driven/
Monitoringintegrations.monitoring.defaults, per-workload metrics, auto-exposed metrics portADR 008 · ADR 016 · 02-examples/06-monitoring/
Secrets (ESO)integrations.eso.enabled, integrations.eso.secretStores, integrations.eso.externalSecretsADR 015 · 02-examples/07-external-secrets/
Identity & RBACserviceAccount (create defaults to true), rbac (enabled, clusterScoped, rules), automountServiceAccountToken02-examples/07-external-secrets/
Pod-level policiespodSecurityContext, securityContext, resources (root-level defaults: cpu: 10m, memory: 32Mi), terminationGracePeriodSeconds, podDisruptionBudget, topologySpreadConstraints (native k8s list), priorityClassName, nodeSelector, affinity, tolerations, hostAliases—
Volumesvolumes, volumeMounts (root-level maps; per-workload override)ADR 004
Argo CD integrationsintegrations.argocd.syncWaves, integrations.argocd.imageUpdaterADR 010
Stakater Reloaderintegrations.stakater.reloaderADR 017

For a working configuration in each area, follow the example links above. For the full value shape, see values.schema.json.

Cross-cutting rules

The behaviours below span multiple keys; reading the values.yaml comment on any single one doesn’t tell you the whole story.

Environment variable merge order

For each container, env is composed in this order (later layers win on key collision; non-colliding keys union):

NAMESPACE (fieldRef)  →  global.env  →  env  →  workload.env  →  container.env

Per-workload opt-out: inherit.env.{global,root}: false (skip env layers), inherit.envSecrets: false (exclude root envSecrets from envFrom), inherit.configMaps: false (skip root envConfigMaps). See ADR 003.

Label precedence on workload resources

  • app.kubernetes.io/name and app.kubernetes.io/instance are always emitted on workload selectors and pod template labels, regardless of any toggle. The Kubernetes API forbids selector mutation; the chart guarantees they’re present.
  • commonLabels cannot shadow chart-managed app.kubernetes.io/* keys — chart values silently win.
  • labels.standard.{partOf,version,managedBy,enabled} toggles affect singletons (ServiceAccount, Ingress, Routes, etc.) but not workload selectors.

See ADR 011.

Port ordering inside Service / pod template

When a workload’s service.ports (or per-container ports) is iterated:

http (if present)  →  others alphabetically  →  metrics (if present)

Auto-injected metrics port (from integrations.monitoring.defaults.exposeService) lands last. See ADR 014 and ADR 016.

The single-port service form

A workload’s Service takes ports either as the service.ports.<name> map or as the shorthand pair service.port / service.targetPort. In the shorthand, each side falls back to the other, in the Service and in the container spec alike:

service:
  enabled: true
  port: 80          # container listens on 80 as well
service:
  enabled: true
  port: 80
  targetPort: 8080  # container listens on 8080

The map form falls back the same way: service.ports.<name>.port alone is enough, and the container port follows it. An entry with neither key has nothing to fall back to and fails the render:

service.ports for workload "web": port "http" declares neither port nor
targetPort. Set port, or a numeric targetPort for it to fall back to.

The fallback runs one way only. spec.ports[].port is a number, so an entry that names its targetPort has to declare port itself:

service.ports for workload "web": port "grpc" names its targetPort "grpc"
and declares no port. A Service port is a number and cannot fall back to a
name, so set port explicitly.

An enabled Service with neither form, and no metrics port injected by integrations.monitoring.defaults.exposeService, fails the render. A client-facing Service has nothing sensible to default to, unlike headlessService, which falls back to port 80 because a StatefulSet needs its governing Service to exist at all. The exception is type: ExternalName, which resolves to a DNS name, carries no virtual IP and so needs no ports; a portless one renders without a ports block, and any ports it does declare are still passed through.

Named targetPort and the workload ports map

A Service targetPort may name a port instead of numbering it. containerPort is an integer, so the chart derives container ports from numeric targets only: a named targetPort is passed through to the Service as written and adds nothing to the container spec. Kubernetes resolves the name against whichever container in the Pod declares it.

Declare the port on the container that serves it. A workload’s own ports map replaces the Service-derived container ports entirely, which leaves the Service free to refer to the port by name:

deployments:
  web:
    ports:
      grpc:
        containerPort: 9000
    service:
      enabled: true
      ports:
        grpc:
          port: 9000
          targetPort: grpc

A sidecar can serve it as well. A Service port whose targetPort names a port one of the workload’s sidecars declares belongs to that sidecar’s container, and the main container leaves it out:

deployments:
  web:
    service:
      ports:
        http:
          port: 80
          targetPort: 8080       # containerPort on the web container
        proxy:
          port: 9000
          targetPort: envoy-admin
    sidecars:
      envoy:
        image:
          repository: envoyproxy/envoy
          tag: v1.31-latest
        ports:
          envoy-admin:
            containerPort: 9901  # the port the Service name resolves to

The chart does not check that some container declares the name. A container added by an admission webhook — a service-mesh proxy, for one — declares ports no values key describes, and a Service may target those by name:

deployments:
  web:
    ports:
      http:
        containerPort: 8080
    service:
      ports:
        http:
          port: 80
          targetPort: http
        envoy-prom:
          port: 15090
          targetPort: http-envoy-prom  # declared by the injected proxy

A name nothing in the Pod declares leaves that Service port without endpoints, so a typo shows up as an empty kubectl get endpointslices rather than as a render error.

Service port appProtocol

Both Service port shapes accept an optional appProtocol — the single-port service.port/service.targetPort form and the service.ports.<name> map form (and their headlessService equivalents on StatefulSets). It passes straight through to spec.ports[].appProtocol, the native Kubernetes Service field.

Set it when a port speaks something other than plain HTTP/1.1 and the consumer needs to know — most commonly kubernetes.io/h2c on a gRPC port, so a Gateway API implementation routing a GRPCRoute to that Service configures the upstream connection as HTTP/2 cleartext instead of assuming HTTP/1.1:

service:
  ports:
    grpc:
      port: 9000
      targetPort: 9000
      appProtocol: kubernetes.io/h2c

Dropping the workload suffix from names

Every per-workload resource is named <fullname>-<workloadName>, and the workload name also lands in app.kubernetes.io/name while app.kubernetes.io/instance carries the full construction. A release that runs one workload can drop that suffix:

naming:
  omitWorkloadSuffix: true   # default: false

With it on, a release named myrel renders myrel-universal-helm-chart for the Deployment, the Service, the HPA, the PDB, the VPA, the ScaledObject and the NetworkPolicy, and keeps the usual trailing parts elsewhere: -headless, -metrics, -config. Labels and selectors follow: app.kubernetes.io/name becomes the chart name (nameOverride when set) and app.kubernetes.io/instance becomes <fullname>.

Note that <fullname> is not what singleton resources carry. uhc.labels puts app.kubernetes.io/instance: <release name> on the ServiceAccount, Ingress and Routes, and that stays as it is. So a release myrel gives the Deployment instance: myrel-universal-helm-chart and the ServiceAccount instance: myrel. A kubectl -l selector, or an external ServiceMonitor, has to pick the one it means.

Three things stay where they are.

Container names keep the workload key, so kubectl logs -c api still works and KEDA’s envSourceContainerName still resolves.

jobGroups keep both their names and their labels. The names are built from the group and job keys and never carried a workload suffix; the labels stay on <groupName>-<jobName> / <fullname>-<groupName>-<jobName> because a release can hold any number of job groups next to its one workload. Collapsing them would file every Job pod under the workload’s own selector, and the workload’s Service, NetworkPolicy and PDB would then pick up job pods.

ESO SecretStore and ExternalSecret entries keep their own keys in labels for the same reason.

The flag expects exactly one enabled entry across deployments and statefulSets. With more than one, helm template fails and names the offenders rather than collapsing them onto the same resource names:

naming.omitWorkloadSuffix expects exactly one enabled workload, found 2
(deployments.api, deployments.worker).

Turning the flag on or off for a release that already exists renames its workload and changes an immutable selector, so the Deployment or StatefulSet is replaced rather than updated. Decide before the first install. See ADR 021.

Service naming

A workload’s Service is named <fullname>-<workloadName>. service.nameOverride replaces that name:

deployments:
  frontend:
    service:
      enabled: true
      nameOverride: frontend-web

The override covers the Service object and every chart-rendered reference that defaults to it — Gateway API backendRefs in httpRoute / grpcRoute / tlsRoute rules that carry no explicit serviceName. Labels and selectors are untouched: app.kubernetes.io/name and app.kubernetes.io/instance keep the workload’s own values, so the Service still selects the same pods. The metrics-only Service rendered for a workload that has no Service of its own keeps its <fullname>-<workloadName>-metrics name.

On a StatefulSet the governing headless Service has its own key, headlessService.nameOverride, and spec.serviceName follows it. With headlessService.enabled: false that key is required and names the externally managed Service providing stable per-pod DNS. statefulSets.<name>.serviceName is the older spelling of the same value and still works; headlessService.nameOverride wins when both are set.

Either override is checked against the 63-character ceiling the chart applies to every constructed name, and against the other Services in the release. Two workloads resolving to one Service name, or a StatefulSet whose client and headless Services land on the same name, fail the render:

Service name "shared-svc" is claimed by both deployments.a.service
and deployments.b.service.

headlessService.enabled: false is the one case allowed to look like a collision, because the name usually belongs to a Service outside the release. The one name inside it that also works is the workload’s own client Service with clusterIP: None, which is headless and already selects these pods. Anything else the chart renders leaves spec.serviceName on a Service that never provides per-pod DNS, and the render fails:

statefulSets.db.headlessService is disabled, so spec.serviceName points at
"shared" — but that name belongs to statefulSets.db.service, a Service this
chart renders itself.

jobGroups group → job merge

For each per-job field:

  • maps (env, podAnnotations, metadataAnnotations) — replace per key; later wins.
  • lists (envSecrets, envConfigMaps, tolerations) — concat.
  • topologySpreadConstraints — the job list replaces the group list, and either replaces the root list. Kubernetes rejects two constraints with the same topologyKey and whenUnsatisfiable, so the lists are never concatenated. topologySpreadConstraints: [] on a group or job opts out.
  • volumes / volumeMounts — replace by name (a volume can’t be partially emptyDir and partially configMap).

hashSuffix: true together with a delete-policy that removes the Job after success is rejected fail-fast — the Job would re-run on every sync. See ADR 005 and ADR 012.

HTTPRoute rule fields (name, timeouts, retry, sessionPersistence)

Every HTTPRoute rule (in httpRoute.rules, httpRoutes.<n>.rules, and per-workload deployments.<w>.httpRoute.rules / statefulSets.<w>.httpRoute.rules) accepts four optional field groups beyond matches, filters, and backendRefs:

FieldTypeDescription
namestring (SectionName: lowercase RFC 1123 label, optionally dotted, max 253 chars)Identifies the rule. Surfaced in Gateway API status conditions, access logs, and distributed traces. MUST be unique within the route.
timeouts.requeststring (Gateway API Duration, GEP-2257)Total time budget for the complete HTTP request/response cycle, measured from when the gateway forwards the request. "0s" disables the timeout. Example: "30s".
timeouts.backendRequeststring (Gateway API Duration)Time budget for the backend to process the request. Must be ≤ timeouts.request. Example: "10s".
retry.attemptsinteger (≥ 0)Maximum number of retry attempts.
retry.codesarray of integer (400–599)HTTP status codes that trigger a retry (e.g. [503, 504]). Implementations MUST support 500/502/503/504 as retriable.
retry.backoffstring (Gateway API Duration)Minimum wait between retry attempts. Example: "1s".
sessionPersistence.sessionNamestring (1–128 chars)Identifier the implementation uses for the persistence value (cookie name or header name, depending on type).
sessionPersistence.typeenum: Cookie | HeaderPersistence mechanism. Defaults to Cookie server-side when omitted.
sessionPersistence.absoluteTimeoutstring (Gateway API Duration)Maximum lifetime of the session, regardless of activity. Example: "1h".
sessionPersistence.idleTimeoutstring (Gateway API Duration)Maximum idle time before the session expires. Example: "10m".
sessionPersistence.cookieConfig.lifetimeTypeenum: Permanent | SessionCookie lifetime style (relevant only when type: Cookie).

All four field groups are optional and independently combinable — you can set timeouts without retry, or name without either. Fields absent from a rule are omitted from the rendered manifest; they do not inherit from sibling rules or the route root.

Schema strictness. Each rule entry is validated against a strict schema (additionalProperties: false) listing only the fields above plus matches, filters, backendRefs, serviceName, and servicePort. Unknown keys (typos, deprecated fields, or Gateway API fields not yet wired into the chart) fail at helm template time with a clear validation error rather than being silently dropped. If you depend on a Gateway API HTTPRouteRule field not in this list, open an issue or submit a PR — see ADR 009.

Gateway API channel and version requirements

The chart’s CRD-channel detection runs at template time when gateway.networking.k8s.io/v1 is present in Capabilities.APIVersions and emits a fail-fast error if the field’s required CRD channel is missing. Detection is skipped when rendering offline (helm template without --api-versions).

FieldMinimum Gateway APIChannelDetection marker
namev1.4.0 (Standard) or v1.2.0+ (Experimental)Standard since v1.4.0; Experimental from v1.2 to v1.3gateway.networking.k8s.io/v1/BackendTLSPolicy (Standard, graduated in v1.4.0) or gateway.networking.k8s.io/v1alpha2/TCPRoute (Experimental)
timeouts.*v1.2.0Standardnone — implied by gateway.networking.k8s.io/v1
retry.*v1.2.0Experimental only (still Experimental as of v1.5.1)gateway.networking.k8s.io/v1alpha2/TCPRoute (Experimental)
sessionPersistence.*v1.2.0Experimental only (still Experimental as of v1.5.1)gateway.networking.k8s.io/v1alpha2/TCPRoute (Experimental)

If your cluster runs Standard channel CRDs older than v1.4 and you need name, you must install the Experimental channel; if you need retry or sessionPersistence at all, you must install the Experimental channel — Standard CRDs reject those fields.

See the 04-gateway-api example for a working configuration and Gateway API’s release channels for installation guidance.

GRPCRoute rule fields (name, sessionPersistence)

Every GRPCRoute rule (in grpcRoute.rules, grpcRoutes.<n>.rules, and per-workload deployments.<w>.grpcRoute.rules / statefulSets.<w>.grpcRoute.rules) accepts two optional field groups beyond matches, filters, and backendRefs:

FieldTypeDescription
namestring (SectionName: lowercase RFC 1123 label, optionally dotted, max 253 chars)Identifies the rule. Surfaced in Gateway API status conditions and traces. MUST be unique within the route.
sessionPersistence.sessionNamestring (1–128 chars)Identifier the implementation uses for the persistence value (cookie name or header name, depending on type).
sessionPersistence.typeenum: Cookie | HeaderPersistence mechanism. Defaults to Cookie server-side when omitted.
sessionPersistence.absoluteTimeoutstring (Gateway API Duration)Maximum lifetime of the session, regardless of activity. Example: "1h".
sessionPersistence.idleTimeoutstring (Gateway API Duration)Maximum idle time before the session expires. Example: "10m".
sessionPersistence.cookieConfig.lifetimeTypeenum: Permanent | SessionCookie lifetime style (relevant only when type: Cookie).

Both groups are independently optional. GRPCRouteRule has no timeouts or retry — those are HTTPRoute-only fields in the Gateway API spec.

Schema strictness. Each rule entry is validated against a strict schema (additionalProperties: false) listing only name, matches, filters, backendRefs, serviceName, servicePort, and sessionPersistence. Unknown keys fail at helm template time.

Gateway API channel and version requirements

FieldMinimum Gateway APIChannelDetection marker
namev1.4.0 (Standard) or v1.2.0+ (Experimental)Standard since v1.4.0; Experimental from v1.2 to v1.3gateway.networking.k8s.io/v1/BackendTLSPolicy (Standard, graduated in v1.4.0) or gateway.networking.k8s.io/v1alpha2/TCPRoute (Experimental)
sessionPersistence.*v1.2.0Experimental only (still Experimental as of v1.5.1)gateway.networking.k8s.io/v1alpha2/TCPRoute (Experimental)

The chart fails fast at helm template time when gateway.networking.k8s.io/v1 is present but the required channel marker is not. Detection is skipped offline (helm template without --api-versions).

TLSRoute rule fields (name)

Every TLSRoute rule (in tlsRoute.rules, tlsRoutes.<n>.rules, and per-workload deployments.<w>.tlsRoute.rules / statefulSets.<w>.tlsRoute.rules) accepts one optional field beyond backendRefs:

FieldTypeDescription
namestring (SectionName: lowercase RFC 1123 label, optionally dotted, max 253 chars)Identifies the rule. MUST be unique within the route.

TLSRouteRule has no matches, filters, timeouts, retry, or sessionPersistence — TLS is L4 passthrough; the spec defines only name and backendRefs.

Schema strictness. Each rule entry is validated against a strict schema (additionalProperties: false) listing only name, backendRefs, serviceName, and servicePort. Unknown keys fail at helm template time.

TLSRoute itself is in the Gateway API Experimental channel (gateway.networking.k8s.io/v1alpha2), so any use of TLSRoute already presupposes Experimental CRDs — name adds no further channel requirement beyond TLSRoute itself.

Autoscaler mutual exclusion

  • hpa.enabled: true and keda.enabled: true on the same workload is fail-fast. Both manage spec.replicas; both running is undefined behaviour.
  • verticalPodAutoscaler is orthogonal to both (touches resources.requests, not replicas).
  • When any HPA-class scaler is on, spec.replicas is omitted from the rendered manifest so Helm doesn’t bounce the count on every apply.

See ADR 007.

KEDA advanced pass-through

keda.advanced goes into the ScaledObject’s spec.advanced verbatim. The chart checks that it is an object and stops there, so restoreToOriginalReplicaCount, horizontalPodAutoscalerConfig (name, behavior) and scalingModifiers all work, and so does anything KEDA adds later without a chart release in between.

deployments:
  worker:
    keda:
      enabled: true
      maxReplicas: 20
      triggers:
        - type: kafka
          metadata: {...}
      advanced:
        restoreToOriginalReplicaCount: true
        horizontalPodAutoscalerConfig:
          behavior:
            scaleUp:
              stabilizationWindowSeconds: 60
              policies:
                - type: Pods
                  value: 1
                  periodSeconds: 60

behavior is the Kubernetes HPA scaling-behavior shape, applied to the HPA that KEDA creates. It is worth setting on a lag-driven trigger: the HPA default doubles the replica count every 15 seconds, which overshoots on a queue that drains quickly. The example above adds one pod a minute instead.

Because nothing here is validated field by field, a typo inside advanced reaches the cluster and is rejected by the KEDA CRD rather than by helm template. Check the field names against the ScaledObject spec for the KEDA version you run.

Scheduling field inheritance

tolerations, affinity, nodeSelector and topologySpreadConstraints all follow the same rule: if the workload defines the field, it replaces the root value entirely. If the workload omits the field, the root value is inherited. To disable root inheritance without providing a replacement, set the field to an empty value (tolerations: [], affinity: {}, topologySpreadConstraints: []).

Topology spread constraint fields

Each entry of topologySpreadConstraints (root, deployments.<name>, statefulSets.<name>, jobGroups.<group> and jobGroups.<group>.jobs.<job>) accepts the Kubernetes fields below and nothing else; an unknown key fails schema validation.

FieldRequiredNotes
maxSkewyesInteger, 1 or more.
topologyKeyyesNode label key.
whenUnsatisfiableyesDoNotSchedule or ScheduleAnyway.
labelSelectornoDefaults to the workload’s selector labels when omitted or empty.
matchLabelKeysnoList of pod label keys, e.g. pod-template-hash to spread each Deployment revision on its own.
minDomainsnoInteger, 1 or more. Kubernetes accepts it only with whenUnsatisfiable: DoNotSchedule, and so does the schema.
nodeAffinityPolicynoHonor or Ignore. Kubernetes treats an unset value as Honor.
nodeTaintsPolicynoHonor or Ignore. Kubernetes treats an unset value as Ignore, so domains made only of tainted nodes still count toward the skew.
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    minDomains: 3
    nodeTaintsPolicy: Honor
    matchLabelKeys:
      - pod-template-hash

PodDisruptionBudget: minAvailable vs maxUnavailable

A PodDisruptionBudget takes one of the two. A workload-level podDisruptionBudget block replaces the root block as a whole, so it carries only what it states: setting both there fails with podDisruptionBudget for workload "<name>": cannot set both minAvailable and maxUnavailable, and a block with neither renders maxUnavailable: 1.

The root block always contains the chart default minAvailable: 1. A root maxUnavailable takes precedence over minAvailable, whether that is the default or a value you set:

podDisruptionBudget:
  enabled: true
  maxUnavailable: 1   # rendered; minAvailable is ignored

maxUnavailable: 0 and minAvailable: "100%" allow no voluntary evictions: kubectl drain and node autoscalers wait on those pods until the budget changes. The same holds for minAvailable: 1 on a single-replica workload.

Init containers and sidecars share one container shape

deployments.<name>.initContainers and deployments.<name>.sidecars (same under statefulSets.<name>) are maps keyed by container name. Both flow through the same renderer as the main container: image, command/args, env, envSecrets, volumeMounts, resources, securityContext, lifecycle. Both inherit the parent workload’s inherit.env / inherit.configMaps / inherit.configMapMount flags. Neither inherits root-level volumeMounts.<containerName> — declare local mounts inline. Render order is sortAlpha; for initContainers Kubernetes runs them sequentially in declared order, so prefix names with 01-, 02-, … when run order matters (same convention as jobGroups[*].tasks). Reloader does not re-trigger init containers — they run only on Pod creation, so a config change rolls the workload and a fresh init pass runs. Two exceptions for initContainers: probesEnabled / probe blocks (readinessProbe, livenessProbe, startupProbe) and lifecycle are rejected — Kubernetes does not allow these on standard init containers, so helm template fail-fasts if either is set on an init entry.

ConfigMap names

A workload with createConfigmap: true gets <fullname>-<workloadName>-config, and an entry in configMaps gets <fullname>-<key>. The two shapes meet whenever a key spells out the other’s suffix, so configMaps.web-config beside a workload named web lands both on one name, and under naming.omitWorkloadSuffix a key of just config is enough. That fails the render rather than producing two documents Kubernetes will refuse:

ConfigMap name "myrel-universal-helm-chart-web-config" is claimed by both
deployments.web.createConfigmap and configMaps.web-config.

The same check covers a deployments.<name> and a statefulSets.<name> sharing a key while both set createConfigmap. Disabled workloads and configMaps entries are ignored, since neither renders.

Chart-owned vs external name resolution

When the same name appears in both a chart-owned map (configMaps, integrations.eso.externalSecrets) and an external reference list (envConfigMaps, envSecrets):

  • chart-owned wins: the rendered reference points at <release-fullname>-<name>.
  • external is silently shadowed.
  • having both with the same name is a smell — pick distinct names.

See ADR 015.

Where to go next

Schema and IDE setup

values.schema.json is a JSON Schema (draft 2020-12) that Helm runs against your values.yaml on every render. It catches typos, wrong types and out-of-enum values before templates execute, so the failure mode is a clear “this field has the wrong shape” rather than a confusing rendered manifest.

What the schema catches

  • field types (string, integer, boolean, object, array);
  • enum constraints (pullPolicy: Always | IfNotPresent | Never, provider: prometheus | victoriametrics, …);
  • closed objects (additionalProperties: false) — typoed keys at the leaf are rejected;
  • minimum / maximum on integers;
  • regex on map keys (port names ≤15 chars, DNS-1123 labels, …).

What the schema doesn’t catch

Cross-field invariants that depend on values in different sections — for example “HPA and KEDA on the same workload is invalid” or “hashSuffix: true together with a delete-policy that removes the Job after success”. These live as fail calls in the templates. Rationale: ADR 013 — Schema-driven validation.

Schema layout

Two recurring shapes:

Closed object — every field is enumerated; typos fail.

"image": {
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "repository": { "type": "string" },
    "tag":        { "type": "string" },
    "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
  }
}

Open map — keys are user-provided names (workloads, ports, hosts), values must match a schema.

"deployments": {
  "type": "object",
  "additionalProperties": { "$ref": "#/$defs/workload" }
}

For the full schema source, see values.schema.json.

IDE setup

yaml-language-server / Red Hat YAML extension (VS Code, Neovim, others)

Add a directive at the top of any values file you author:

# yaml-language-server: $schema=https://raw.githubusercontent.com/purisev/universal-helm-chart/v3.3.0/values.schema.json
deployments:
  api:
    image:
      repository: nginx
      tag: "1.27"

Autocompletion, hover docs and inline error squiggles work from this point on.

JetBrains IDEs (IntelliJ, GoLand, PyCharm, …)

Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings:

  • Schema file or URL: https://raw.githubusercontent.com/purisev/universal-helm-chart/v3.3.0/values.schema.json
  • Schema version: JSON Schema 2020-12 (the latest)
  • File path pattern: *values*.yaml (or per-project glob).

helm lint

Schema validation runs as part of every helm lint and helm install/upgrade. No setup required. CI calls it on every PR.

Manual validation outside Helm

For a fast editor pre-check loop without Helm in the path:

ajv validate \
  --spec=draft2020 \
  -s values.schema.json \
  -d values.yaml

Use this when iterating on overlay values in a config repo where Helm isn’t installed.

Pinning the schema URL

The v3.3.0 segment in the schema URL above is a git tag. Replace it with the chart version you’re targeting (or a branch name if you’re tracking a development line). For an OCI artifact, the schema is bundled inside the chart and pulled on helm template/helm install automatically — IDE setup is only needed when authoring values outside an active helm invocation.

Compatibility

Versions and APIs the chart targets. The intent here is to be honest about what the chart renders rather than to claim a tested SemVer matrix that rots on every operator release.

Helm

  • 3.8+ — required for OCI registries (oci://ghcr.io/...).
  • 3.14+ — recommended; brings the latest schema-validation features used by values.schema.json (draft 2020-12 keywords).

Kubernetes APIs the chart renders

The chart emits resources from these stable API groups:

ResourceAPI group / version
Deployment, StatefulSetapps/v1
Job, CronJobbatch/v1
HorizontalPodAutoscalerautoscaling/v2
Service, ServiceAccount, ConfigMapv1
Ingress, NetworkPolicynetworking.k8s.io/v1
Role, RoleBindingrbac.authorization.k8s.io/v1
PodDisruptionBudgetpolicy/v1

Implied minimum cluster: Kubernetes 1.21+. That’s the release where batch/v1 CronJob graduated to GA; everything else the chart uses has been stable longer. The chart is exercised against the two latest stable Kubernetes versions in CI.

Optional CRDs (per integration)

Each integration is opt-in; install only the CRDs for the integrations you enable.

FeatureAPI group / version chart renders
Gateway API routes — HTTPRoute, GRPCRoutegateway.networking.k8s.io/v1
Gateway API TLSRoutegateway.networking.k8s.io/v1alpha2 (Experimental channel only — not in Standard as of Gateway API v1.5.x)
Gateway API ReferenceGrantgateway.networking.k8s.io/v1beta1
External Secrets Operatorexternal-secrets.io/v1 (auto-detects v1beta1 fallback via .Capabilities)
KEDA ScaledObjectkeda.sh/v1alpha1
Prometheus Operator monitors — ServiceMonitor, PodMonitormonitoring.coreos.com/v1
VictoriaMetrics scrapes — VMServiceScrape, VMPodScrapeoperator.victoriametrics.com/v1beta1
Vertical Pod Autoscalerautoscaling.k8s.io/v1
Argo CD Image Updaterargocd-image-updater.argoproj.io/v1alpha1

Stakater Reloader is annotation-driven — no CRDs to install on its side; just the Reloader controller in the cluster.

Tested with

The chart renders against the current stable release of each operator. The auto-detection paths (.Capabilities.APIVersions.Has) handle the common transition windows between operator API versions. Open an issue if you find an incompatibility on an older or pre-release version — we’ll patch the detection.

Outside the chart

The chart doesn’t ship the operators themselves. That’s deliberate: a universal chart shouldn’t take a dependency on a specific Prometheus / VictoriaMetrics / KEDA / ESO release line. Install operators with their own published charts, then turn the matching integrations.<name>.enabled switch on.

Migration

What changes between releases that may require values-file edits when you upgrade. The chart’s value surface is intentionally large; most edits are additive, but a few iterations on the release-2.0.0 line tightened the schema in ways that turn previously-silent no-ops into render-time errors. Most of the changes below reject values that never had any rendered effect; the 63-char enforcement (last section) is the exception — it can additionally reject values that previously rendered a working manifest whose constructed name was too long to be reused as the app.kubernetes.io/instance label value the chart sets on the same resource.

3.3.0

No values-file edits are required for values that Kubernetes accepted before.

topologySpreadConstraints entries are validated and rendered in full

minDomains, nodeAffinityPolicy and nodeTaintsPolicy are rendered into the pod template. 3.2.0 accepted them in values and dropped them, so a release that already sets any of the three gets a changed pod template and one rollout of those pods.

The schema now describes the entry, and these shapes fail validation:

  • A key outside maxSkew, topologyKey, whenUnsatisfiable, labelSelector, matchLabelKeys, minDomains, nodeAffinityPolicy, nodeTaintsPolicy. 3.2.0 dropped such keys silently; remove them or fix the spelling.
  • An entry without maxSkew, topologyKey or whenUnsatisfiable. 3.2.0 rendered the missing field empty, which Kubernetes rejects.
  • minDomains with whenUnsatisfiable: ScheduleAnyway, which Kubernetes rejects.

See Topology spread constraint fields.

topologySpreadConstraints on jobGroups

A group or a job can set its own list; it replaces the root list for those pods. A job that sets it gets a new hashSuffix, so the Job is created under a new name and runs again.

Root podDisruptionBudget.maxUnavailable renders without clearing minAvailable

3.2.0 failed on a root maxUnavailable unless values also set minAvailable: null, because the chart default minAvailable: 1 was always merged in. The root maxUnavailable now takes precedence. A root block that sets both minAvailable and maxUnavailable renders maxUnavailable where 3.2.0 failed. Workload-level blocks are unchanged.

3.2.0

No values-file edits are required. Two things are worth checking before the upgrade, and a few shapes that never produced an installable manifest now fail at render time.

Finished Jobs under exposeService have to go before the upgrade

With integrations.monitoring.defaults.exposeService.enabled: true, 3.1.0 declared the metrics container port on every container, job containers included. 3.2.0 declares it on the main container of a Deployment or StatefulSet only, the one that serves the endpoint.

The name suffix from hashSuffix is computed from the job’s own values, which do not change, so a Job left in the cluster by 3.1.0 keeps its name while its pod template loses the port. spec.template of a Job is immutable, so Kubernetes rejects the update with a field is immutable error.

Delete the finished Jobs of the release first. app.kubernetes.io/part-of carries the chart fullname on every resource, so with the default labels:

kubectl get job -n <namespace> -l app.kubernetes.io/part-of=<fullname>
kubectl delete job -n <namespace> -l app.kubernetes.io/part-of=<fullname>

The upgrade recreates them under the same names, and they run again.

Releases that leave exposeService off are unaffected.

One rollout of the pods

The pod template of a Deployment or StatefulSet changes in two cases, and the upgrade rolls those pods once:

  • service: written without enabled: true. The Service rendered in 3.1.0 as well, but the container declared no ports; it now declares the ones derived from the Service, as it does with enabled: true.
  • Sidecars and init containers of a workload whose metrics port the chart exposes. They no longer declare the metrics port.

Shapes that fail at render time

Each of these rendered in 3.1.0 into manifests that could not be applied:

  • An enabled service with no port in either form rendered port: null. Set service.port / service.targetPort or service.ports.<name>.port. type: ExternalName is exempt and renders without a ports block.
  • A service.ports.<name> or headlessService.ports.<name> entry with neither port nor targetPort, or with a named targetPort and no port. Set port.
  • Two ConfigMaps under one name: a configMaps.<key> entry spelling out the <workload>-config name of a workload with createConfigmap: true (configMaps.web-config beside a workload web), or a Deployment and a StatefulSet with the same key that both set createConfigmap. Rename one of them.

A named targetPort with no matching ports entry used to put the name into containerPort. It now renders: the name stays on the Service and adds no container port. See Named targetPort and the workload ports map.

3.0.0

StatefulSet service no longer merges into the headless Service

statefulSets.<name>.service always renders as its own, independent Service now. Previously, whenever headlessService stayed enabled (the default), service.* fields configured the single headless Service object instead of creating a separate one — so a StatefulSet couldn’t have a headless Service for stable per-pod DNS and a regular client-facing Service at the same time without setting serviceName to work around it.

Headless-specific fields moved from service.* to headlessService.*: ports, annotations, publishNotReadyAddresses, ipFamilies, ipFamilyPolicy.

# Before — service.* configured the headless Service:
statefulSets:
  db:
    service:
      ports:
        db:
          port: 5432
          targetPort: 5432
      annotations:
        example.com/foo: bar

# After — headlessService.* configures the headless Service; service.* now
# always renders a separate, independent client-facing Service and is
# opt-in (set service.enabled: true, or any service.* field, to render it):
statefulSets:
  db:
    headlessService:
      ports:
        db:
          port: 5432
          targetPort: 5432
      annotations:
        example.com/foo: bar

service also gained the rest of the ServiceSpec fields (externalName, loadBalancerSourceRanges, sessionAffinityConfig, trafficDistribution, etc.) — service only, not headlessService: a headless Service has no virtual IP, so the load-balancer, traffic-policy, and session-affinity fields have no meaning there and the schema rejects them.

2.0.0

Per-workload httpRoute / grpcRoute / tlsRoute accept only enabled, priority, rules

The chart’s HTTPRoute / GRPCRoute / TLSRoute model is one route resource per release: parentRefs, hostnames, annotations, and metadataAnnotations live on the singleton root entry (.Values.httpRoute.*) or on a named entry under the plural map (.Values.httpRoutes.<name>.*), and the per-workload entries (deployments.<wl>.httpRoute, statefulSets.<wl>.httpRoute, and the gRPC / TLS equivalents) contribute only their rules to the merged singleton.

Pre-2.0.0 the schema reused the full route shape at the per-workload level too, accepting parentRefs / hostnames / annotations / metadataAnnotations and silently dropping them at render time. The schema now rejects those fields on per-workload entries with additional properties '<field>' not allowed.

If your values fail validation, move the affected fields up:

# Before — schema-rejected on a per-workload route:
deployments:
  web:
    httpRoute:
      enabled: true
      priority: 10
      parentRefs:
        - name: my-gateway
          namespace: gateway-ns
      hostnames:
        - api.example.com
      rules:
        - matches: [...]

# After — parentRefs / hostnames live on the singleton, shared across every
# workload that contributes rules:
httpRoute:
  enabled: true
  parentRefs:
    - name: my-gateway
      namespace: gateway-ns
  hostnames:
    - api.example.com

deployments:
  web:
    httpRoute:
      enabled: true
      priority: 10
      rules:
        - matches: [...]

If you genuinely need different parentRefs or hostnames per workload, declare a separate route under the plural map (httpRoutes.<name>) — that entry takes the full route shape (and is itself rendered as its own HTTPRoute resource named <release>-<name>).

Name-length enforcement split by resource kind

The chart enforces two different length ceilings depending on how the constructed name is used:

  • 63 characters — applied to Service names and app.kubernetes.io/instance label values. Kubernetes DNS-1123 label rules cap both at 63 chars; a name longer than this produces a manifest the API server rejects (for Services) or that breaks selector lookups (for label values).
  • 253 characters — applied to all other chart-constructed names: ConfigMap, Ingress, HTTPRoute, GRPCRoute, TLSRoute, ReferenceGrant, ServiceMonitor, PodMonitor. These are DNS-1123 subdomain names in Kubernetes, which allow up to 253 chars.

The chart fails fast at helm template time with a precise error naming the kind, the offending value, its length, and the values keys to shorten (Helm release name, .Values.fullnameOverride, or the workload / entry key).

If you hit a length error, shorten one of:

  • The Helm release name — helm install --name <shorter> / Argo CD metadata.name on the Application.
  • .Values.fullnameOverride — overrides <release>-<chart> with whatever you set.
  • The workload key under deployments / statefulSets / configMaps / jobGroups / httpRoutes / etc.

Names supplied verbatim by the user (integrations.argocd.imageUpdater.name, serviceAccount.name, ESO target.name) are not asserted — those are the user’s contract with the cluster’s DNS limits.

metadataAnnotations dropped from httpRoute / grpcRoute / tlsRoute

Both the singleton (.Values.httpRoute.metadataAnnotations) and the plural-map entries (.Values.httpRoutes.<key>.metadataAnnotations, and the gRPC / TLS equivalents) accepted a metadataAnnotations block that no template ever read. Every route callsite passes $route.annotations to the rendered Route’s metadata.annotations, never $route.metadataAnnotations. The schema now rejects the field with additional properties 'metadataAnnotations' not allowed — move any values you had under it to the existing annotations field (which is the field the rendered Route picks up).

ingress.tls changed from map to native list

ingress.tls and per-entry ingresses.<name>.tls are now a native Kubernetes list instead of a map keyed by secret name. The secretName field is optional — omit it for SNI-only TLS routing.

# Before (map, secret name as key — schema-rejected):
ingress:
  tls:
    my-tls-secret:
      hosts:
        - example.com

# After (native k8s list):
ingress:
  tls:
    - hosts:
        - example.com
      secretName: my-tls-secret   # optional

topologySpreadConstraints changed to native list

The topologySpreadConstraints wrapper object ({enabled, constraints: [...]}) is removed. The value is now a native Kubernetes list directly, at both root and per-workload level.

# Before (object wrapper — schema-rejected):
topologySpreadConstraints:
  enabled: true
  constraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: ScheduleAnyway

# After (native k8s list):
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway

Per-workload override follows the replace-not-concat rule: a workload-level list replaces the root list entirely. Set topologySpreadConstraints: [] to explicitly disable root constraints on a specific workload.

inheritRootSchedParams removed

The per-workload inheritRootSchedParams block ({affinity, tolerations, tsc}) is removed. Scheduling field inheritance now follows the universal rule: workload value replaces root value; omit to inherit; empty value to disable.

# Before:
deployments:
  worker:
    inheritRootSchedParams:
      tolerations: false   # don't inherit root tolerations

# After — set the field explicitly at workload level:
deployments:
  worker:
    tolerations: []        # empty list: no tolerations on this workload

Other tightenings worth knowing about

The schema also closes a number of previously-permissive shapes (additionalProperties: false on every chart-defined object, drop of fields the chart never rendered like statefulSets.<name>.progressDeadlineSeconds, fail-fast for headlessService.enabled: false without serviceName, fail-fast for rbac.enabled without serviceAccount.create). If your values predate the round-by-round polish on the release-2.0.0 branch and validation now rejects them, the message names the offending key and the right shape.

Contributing

Four short reads, in order. The whole section is intentionally lean — a first-time contributor should be able to skim it in five minutes (the e2e page is the exception; skim its job table, come back when you’re adding a live-cluster scenario).

  • 01-development.md — prerequisites, clone-and-verify, dev loop, common pitfalls.
  • 02-testing.md — helm-unittest cookbook: two patterns, the asserts you need, three steps to add a test.
  • 03-e2e-testing.md — label-gated live-cluster suite: what each job covers, how to run one locally, how to add a scenario.
  • 04-releasing.md — date the changelog, tag, push; CI publishes to oci://ghcr.io/purisev and to GitHub Releases.

The chart’s design rationale lives in ../05-adr/ — read those before proposing changes that affect values.yaml shape.

Local development

Prerequisites

  • Helm 3.14+
  • helm-unittest plugin:
helm plugin install https://github.com/helm-unittest/helm-unittest --verify=false

That’s it.

Clone and verify

git clone https://github.com/purisev/universal-helm-chart.git
cd universal-helm-chart
helm lint .
helm unittest .

Sub-minute end-to-end. If those two commands pass, your environment is ready.

Dev loop

Three commands cover 99% of the work:

CommandWhen to run
helm template my-app .After any template or values change — fastest visual check of the rendered manifests.
helm lint .Before pushing — catches schema mismatches and shape problems CI will also reject.
helm unittest .Before pushing — catches behavioural regressions. Add a new test case in the matching tests/<template>_test.yaml whenever you add a feature.
bash .github/scripts/check-version-pins.shAfter touching Chart.yaml or any install snippet in docs/ — holds every documented version to the one in Chart.yaml.
bash .github/scripts/check-examples.shAfter touching the values shape, the schema or anything under docs/02-examples/ — renders every example, including the values inlined in its Argo CD and Flux manifests, and checks each Ingress backend against the Services the same values render. Needs yq.

To render a single template against custom values:

helm template my-app . -s templates/deployment.yaml -f my-values.yaml

Common pitfalls

  • Changed the values shape? Update values.schema.json in the same commit. Schema and values.yaml drift is the most common reviewer comment.
  • Added a field? Add at least one assertion in tests/<template>_test.yaml. Coverage prevents silent regressions on future refactors.
  • Changed what a user sees? Add a line to the in-flight version’s section of CHANGELOG.md. CI fails while that section is missing or empty.
  • Bumped the chart version? Every Argo CD targetRevision, Flux tag, helm install --version and schema URL under docs/ names it too. check-version-pins.sh lists the ones you missed; CI runs it on every run, the e2e suites wait on it, and the branch preview build will not publish while it is red.
  • Changed an example? Each example carries the same values three times: values.yaml, the Argo CD Application, and the Flux HelmRelease. Change one and the other two go stale. check-examples.sh renders all three and fails on the ones that no longer install — or that name a Service nothing renders.
  • Writing fixtures or example values? Use block-style YAML throughout — multi-line indented maps and lists, never the inline curly-brace / square-bracket form. The chart docs are authored as reference material; keeping the style consistent matters.

Testing

The chart’s regression suite uses helm-unittest — rendering-only, no live cluster. This page is the practical cookbook; for the design rationale see ADR 018. For the live-cluster suite that proves rendered manifests actually work against real controllers, see 03-e2e-testing.md.

Layout

  • One suite per template: tests/<template>_test.yaml covers templates/<template>.yaml.
  • Shared values blocks live in tests/fixtures/. Use a fixture when the same set: block would repeat across two or more tests, or when the values block exceeds ~10 lines.

Run the full suite:

helm unittest .

Two patterns — pick one per test

Inline set: for short cases:

- it: should render Service when enabled
  set:
    image.repository: nginx
    image.tag: alpine
    deployments.web.enabled: true
    deployments.web.service.enabled: true
    deployments.web.service.port: 80
  asserts:
    - isKind:
        of: Service

Fixture file for non-trivial inputs:

- it: should render KEDA ScaledObject for the worker
  values:
    - fixtures/keda-deployment.yaml
  asserts:
    - isKind:
        of: ScaledObject
    - matchRegex:
        path: metadata.name
        pattern: -worker$

The asserts you’ll need

# Verify the rendered kind
- isKind:
    of: Deployment

# Exact field value
- equal:
    path: spec.replicas
    value: 3

# Field shape match
- matchRegex:
    path: spec.template.spec.containers[0].image
    pattern: nginx:.+

# Field absent
- notExists:
    path: spec.replicas
# (or its synonym for null fields)
- isNull:
    path: spec.replicas

# Field present
- isNotNull:
    path: spec.metrics

# Document count in multi-doc render
- hasDocuments:
    count: 2

# Negative test — assert the template fails with a specific message
- failedTemplate:
    errorMessage: "deployments.web: hpa.enabled and keda.enabled are mutually exclusive — both manage spec.replicas. Pick one."

Add a test in three steps

  1. Find the closest existing it: block in the right tests/<template>_test.yaml. Copy it.
  2. Tweak set: (or replace it with values: [fixtures/<name>.yaml]) to reflect the scenario you’re adding. Add or adjust asserts: to match the expected output.
  3. Run helm unittest . — green or fix.

CI runs the same command on every PR; tests passing locally is the same signal as CI green.

E2E testing

helm-unittest (see 02-testing.md) only proves the chart renders the YAML you expect — it never touches a real API server. This suite does: each job spins up a real kind cluster, installs the real controllers a feature depends on (Gateway API + agentgateway, KEDA, External Secrets Operator, cert-manager, Calico, Argo CD, Prometheus/VictoriaMetrics operators, …), installs the chart against it, and asserts on real, live behavior — an HTTP/gRPC/TLS request that actually gets routed, a pod that actually restarts, an autoscaler that actually scales — using Chainsaw as the test runner.

It’s label-gated, not run on every push

Standing up a dozen controllers per push would be slow and wasteful. .github/workflows/e2e.yaml only runs when:

  • the PR carries the e2e label — runs every job, or
  • the PR carries an e2e:<job> label — runs just that job, or
  • the workflow is triggered manually (workflow_dispatch) — runs every job.

A gate job reads the PR’s labels and sets one boolean output per job; every e2e-* job is if: needs.gate.outputs.<job> == 'true'. Label a PR with the narrowest e2e:<job> that covers what you changed — not the blanket e2e label — so CI time stays proportional to the change.

Linting runs first

Once the gate decides at least one suite should run, two preflight jobs go ahead of it: Lint and Test (helm lint, helm template, helm unittest) and Check version pins. Every e2e-* job waits on both, so a chart that does not lint, or a version the docs disagree about, never reaches a kind cluster — fix the lint first, then the suites run on something that can plausibly pass.

Both are the same checks ci.yaml runs, not copies of them. The chart lint lives in .github/actions/lint-chart, a composite action both files invoke, and the version check is .github/scripts/check-version-pins.sh in both. Add a step to the action and it guards the e2e suites too. Running the steps inside the calling job rather than through a reusable workflow keeps the status check named Lint and Test, which is the name branch protection requires.

They are skipped along with everything else when the gate enables no suite, so a PR that never asked for e2e pays nothing.

The jobs

JobLabelCovers
e2e-core-workloadse2e:core-workloadsDeployment rollout, StatefulSet (headless DNS, independent Service), Service routing, ConfigMap automount, envFrom, PodDisruptionBudget eviction, RBAC, container resizePolicy
e2e-jobse2e:jobsjobGroups Job (basic/indexed/suspend-resume/podFailurePolicy), CronJob manual trigger
e2e-autoscalinge2e:autoscalingHPA (structural), KEDA (real cron-driven scale-out and scale-to-zero), VPA (recommendation)
e2e-gateway-ingresse2e:gateway-ingressIngress (real HTTP + real TLS termination), HTTPRoute, GRPCRoute (real gRPC call), TLSRoute (real TLS passthrough by SNI), ReferenceGrant (real cross-namespace deny/allow)
e2e-secrets-confige2e:secrets-configESO SecretStore/ClusterSecretStore against a real single-replica Vault (Bank-Vaults operator), Stakater Reloader on ConfigMap, StatefulSet, and ExternalSecret changes
e2e-observabilitye2e:observabilityServiceMonitor/PodMonitor materialization and real scraping, Prometheus and VictoriaMetrics flavors
e2e-network-policye2e:network-policyNetworkPolicy ingress and egress enforcement (Calico; kind’s default CNI doesn’t enforce NetworkPolicy at all)
e2e-argocde2e:argocdArgo CD Application sync/health, resync idempotency, Image Updater tag detection, PreSync hook ordering

Each job’s cluster only carries the controllers that job’s scenarios need — e2e-secrets-config doesn’t install Gateway API CRDs, e2e-gateway-ingress doesn’t install Vault, and so on.

Layout

test/e2e/
  kind/<job>.yaml            — kind cluster config (some jobs share test/e2e/kind/default.yaml)
  values/<job>.yaml          — scenario-scoped values (not the values.yaml.example kitchen sink)
  fixtures/                  — raw manifests the workflow applies before/alongside the chart (Gateway, RBAC, CRs, …)
  scripts/                   — small helpers a chainsaw script/command step shells out to
  chainsaw/<job>/            — one directory per test scenario, one chainsaw-test.yaml each
  chainsaw/<job>-config.yaml — chainsaw Configuration for that job (default namespace, timeouts)

A scenario directory’s chainsaw-test.yaml is one or more steps, each an apply/assert/script sequence. Prefer script with a poll loop over a bare assert whenever the thing being checked can lag behind an object’s status conditions (a controller’s dataplane catching up to a spec change, a cron window closing, propagating an external system’s state) — several tests in this suite were flaky before switching to that pattern.

Running a job locally

kind create cluster --config test/e2e/kind/<job>.yaml
# install that job's prerequisite controllers — see the corresponding
# steps in .github/workflows/e2e.yaml for the exact commands/versions
helm upgrade --install e2e . -n <namespace> -f test/e2e/values/<job>.yaml --wait --timeout 3m
chainsaw test test/e2e/chainsaw/<job> --config test/e2e/chainsaw/<job>-config.yaml

The workflow file is the source of truth for each job’s exact controller-install steps (versions, Helm repos, wait conditions) — copy them from there rather than guessing, since several depend on version-specific quirks (e.g. cert-manager’s webhook can report Available before its CA bundle has actually propagated).

Adding a scenario

  1. Pick the job whose cluster already has what the scenario needs, or start a new job if it needs a controller none of the existing ones install.
  2. Add a test/e2e/chainsaw/<job>/<scenario>/chainsaw-test.yaml. Extend test/e2e/values/<job>.yaml (and fixtures/ if the scenario needs a raw manifest the chart doesn’t render, like a Gateway or a CR) rather than adding a new values file, unless the scenario needs a materially different install.
  3. Assert on real, observable behavior, not just object shape — a request that gets routed, a pod that restarts, a value that changes — the same bar the existing scenarios hold themselves to. Object-shape-only assertions belong in helm-unittest, which is far cheaper to run.
  4. Label the PR e2e:<job> and confirm the job goes green before merging.

Releasing

The chart is published to GHCR as an OCI artifact under the maintainer’s namespace (oci://ghcr.io/purisev/universal-helm-chart for this repo; ${{ github.repository_owner }} for forks — see Forks below). Two CI workflows under .github/workflows/ handle the publishing automatically.

Cutting a release (tagged build)

  1. Check Chart.yaml. The version field is pinned to the in-flight release line and must already match the version you want to publish — don’t bump per-PR.

    The version-pins CI job checks this for you, and checks that the version in Chart.yaml is also the one every Argo CD targetRevision, Flux tag, helm install --version and schema URL under docs/ names. Given a ref that names a version — a release-X.Y.Z branch or a vX.Y.Z tag — it additionally holds Chart.yaml to that version. CI hands it the branch a PR targets, the branch it comes from and the tag, so the release PR asserts the version too rather than only the PRs into the release branch. Run it locally with:

    TARGET_REFS=release-<X.Y.Z> bash .github/scripts/check-version-pins.sh
    

    It runs on every CI run, and the branch preview build in ci.yaml will not publish while it is red. release.yaml runs it again on the tag itself, where the ref is the version the artifact is about to carry, so a chart cannot reach GHCR under a version that is not the one inside it. The migration guide and the ADRs are skipped, since naming older versions is what they are for.

  2. Date the release in CHANGELOG.md. Every user-visible change goes under the heading of the in-flight version as it lands, and the heading reads ## <X.Y.Z> - unreleased until the release. Replace unreleased with the release date. The version-pins CI job fails while CHANGELOG.md has no notes for the version in Chart.yaml, and release.yaml refuses a tag whose section is still marked unreleased. Print the notes a release will carry with:

    bash .github/scripts/release-notes.sh
    
  3. Tag and push from the matching release branch:

    git checkout release-<X.Y.Z>
    git tag v<X.Y.Z>
    git push origin v<X.Y.Z>
    
  4. CI takes over. release.yaml runs on v* tags: it lints, runs the unittest suite, checks the tag against Chart.yaml and the docs, reads the release notes from CHANGELOG.md, packages the chart, pushes the artifact to oci://ghcr.io/<your-github-namespace> (the workflow resolves your namespace from ${{ github.repository_owner }}), and signs it keylessly via Sigstore/cosign — no key management, the signature is tied to this repo’s GitHub Actions OIDC identity. It then publishes the GitHub Release for the tag with the notes from CHANGELOG.md and three assets: the packaged chart, its Sigstore signature bundle (.sigstore.json) and its build provenance (.intoto.jsonl). A release that already exists for the tag gets the notes and the assets added to it.

  5. Verify the artifact:

    helm pull oci://ghcr.io/purisev/universal-helm-chart --version <X.Y.Z>
    
  6. Verify the signature (optional, proves the artifact was actually built by this repo’s release.yaml and not pushed by hand or from a fork):

    cosign verify \
      --certificate-identity-regexp "^https://github.com/purisev/universal-helm-chart/" \
      --certificate-oidc-issuer https://token.actions.githubusercontent.com \
      ghcr.io/purisev/universal-helm-chart:<X.Y.Z>
    

    The chart attached to the GitHub Release verifies the same way against its own bundle, and its provenance with the GitHub CLI:

    cosign verify-blob \
      --bundle universal-helm-chart-<X.Y.Z>.tgz.sigstore.json \
      --certificate-identity-regexp "^https://github.com/purisev/universal-helm-chart/" \
      --certificate-oidc-issuer https://token.actions.githubusercontent.com \
      universal-helm-chart-<X.Y.Z>.tgz
    
    gh attestation verify universal-helm-chart-<X.Y.Z>.tgz --repo purisev/universal-helm-chart
    

Branch builds (PR previews)

ci.yaml can also publish a preview artifact, tagged <chart-version>-<branch-slug> — for example <X.Y.Z>-feat-xyz. It’s gated behind the publish-preview label (not automatic on every PR) and only runs once linting/tests have actually passed. Apply the label to a non-draft, non-fork PR to trigger it. Reviewers can then pull it directly without cloning:

helm template demo oci://ghcr.io/purisev/universal-helm-chart --version <X.Y.Z>-feat-xyz -f my-values.yaml

What gets published

The OCI artifact contains only what chart consumers need: Chart.yaml, templates/, values.yaml, values.yaml.example, values.schema.json, README.md, CHANGELOG.md, LICENSE, NOTICE, SECURITY.md. Everything else (docs/, tests/, test/, .github/) is excluded via .helmignore.

Forks

Both workflows resolve the GHCR namespace from ${{ github.repository_owner }}, so a fork publishes to oci://ghcr.io/<your-github-user>/universal-helm-chart automatically — no workflow edits required. Tag a release on your fork’s release-<X.Y.Z> branch the same way and release.yaml runs against your own GHCR namespace using your repo’s GITHUB_TOKEN.

Architecture Decision Records

ADRs capture the reasoning behind the chart’s load-bearing design choices — the things that would be hard to reverse and the things a new contributor would otherwise have to reverse-engineer from values.yaml. For working configurations rather than rationale, see ../02-examples/.

Numbering convention

Numbers encode importance, not chronology:

  • 001 is the most foundational decision — everything else assumes it.
  • The tail (015–018) covers narrower refinements.
  • New ADRs append at the next free number; if a new decision belongs higher in the stack, don’t renumber existing ADRs — link to it from the relevant ADR(s) and update this index.

Three-digit prefix; one decision per file.

Index

Template

# NNN — Title

- **Status:** Accepted | Superseded by NNN | Deprecated
- **Date:** YYYY-MM-DD

## Context
What forces are at play. Constraints from Kubernetes, GitOps tools, the chart's universal scope, prior incidents.

## Decision
The choice we made, in one paragraph.

## Consequences
What this enables, what it costs, what it forecloses. Migration notes if it replaced a previous approach.

## Alternatives considered
Brief — one or two lines each.

## References
Value paths (e.g. `integrations.monitoring.defaults.exposeService`), helper names, named blocks in `values.yaml`, related ADRs. **Avoid line numbers** in any file reference — they rot the moment someone reformats the file. Avoid commit SHAs for the same reason — git history is the authoritative timeline.

001 — Universal chart: one chart for all common workloads

  • Status: Accepted
  • Date: 2025-04-25

Context

Most teams running Kubernetes accumulate a stack of “almost the same” Helm charts: one for the API service, one for the worker, one for the cron jobs, one for the migration job, one for the gRPC backend. Each chart copies and slowly diverges in how it computes labels, how it wires in monitoring, how it reads secrets, how it writes pod security context. Bug fixes in one chart don’t propagate. The blast radius of any change is small per chart but the aggregate cost — review burden, audit gaps, drift — is large.

The constraints we live with:

  • Workloads we ship are conventional: Deployment, StatefulSet, Job, CronJob.
  • Most workloads need the same supporting cast: Service, Ingress / Gateway API, ServiceMonitor, ExternalSecret, RBAC, NetworkPolicy, PDB.
  • We deploy through Argo CD and Flux — both expect a single chart reference per Application / HelmRelease.
  • We want a single place to roll out a security tightening (e.g. a seccompProfile default) and have every service pick it up on next deploy.

Decision

Ship one Helm chart that covers all the common workload kinds and their typical neighbours. A release using this chart can declare any combination of:

  • multiple Deployments (via the deployments map);
  • multiple StatefulSets (statefulSets);
  • multiple Job and CronJob bundles (jobGroups);
  • their Services, Ingresses and Gateway API routes;
  • HPA / KEDA / VPA;
  • ServiceMonitor / PodMonitor (Prometheus or VictoriaMetrics flavours);
  • ESO ExternalSecret and SecretStore resources;
  • ConfigMaps owned by the chart;
  • ServiceAccount, RBAC, NetworkPolicy, PDB, Argo CD ImageUpdater, ReferenceGrant.

Everything is opt-in via values.yaml. A release that only fills in deployments.web produces only a Deployment and (if service.enabled) a Service.

Consequences

What this enables:

  • One place to centralise security defaults — podSecurityContext, securityContext, automountServiceAccountToken: false and friends, all defined as top-level blocks in values.yaml.
  • Multiple workloads in a single release co-exist and share global.env, common labels, common annotations, and inherited config — see ADR 003.
  • Bug fixes and feature additions land once and cover every consumer on next chart upgrade.
  • A consistent label / selector contract across all services in a fleet — see ADR 011.

What it costs:

  • The chart’s values surface is wide — readers face a learning cliff. Mitigations: a JSON schema with strict shape validation (ADR 013) and the docs/02-examples/ catalogue. values.yaml itself carries machine-readable defaults only — no inline comments; documentation lives in docs/ and values.schema.json.
  • Some niche workload types are deliberately out of scope.
  • The chart’s blast radius on a change is large — every consumer is a downstream. Mitigations: helm-unittest snapshots (ADR 018) and conservative versioning.

Out of scope (deliberately not shipped)

  • DaemonSet — uncommon in our deployments and has different scaling / scheduling semantics.
  • ReplicaSet, ReplicationController — superseded by Deployment.
  • Pod (bare) — almost always wrong outside of debugging.
  • Plain Endpoints / EndpointSlice — the chart owns Services, not their endpoints.
  • Custom resources outside the integrations we’ve explicitly modelled (Argo CD Application, Flux HelmRelease, etc.) — those consume this chart, they don’t belong inside it.

This list is the contract. Adding a new workload kind is an ADR-worthy decision, not a routine PR.

Alternatives considered

  • One chart per workload kind. Rejected: drift, copy-paste defaults, no DRY for shared neighbours (Service, monitoring, secrets).
  • A library chart consumed by per-service application charts. Rejected: would still leave each service team with a chart to maintain; the universal chart already targets the same DRY goal with a flatter operational model.
  • A code generator (Cookiecutter / Yeoman) producing a chart per service. Rejected: snapshots configuration at generation time; fixes don’t propagate without re-running the generator and resolving merge conflicts.

References

  • Chart.yaml — chart name and version.
  • values.yaml — machine-readable defaults; see docs/03-reference/01-values.md for the annotated reference.
  • Related: ADR 002, ADR 003, ADR 006.

002 — Multi-workload via keyed maps in a single release

  • Status: Accepted
  • Date: 2025-04-25

Context

A single release routinely needs more than one workload of the same kind: an API and a worker, a web app and an admin app, a primary StatefulSet and a read-replica StatefulSet. The chart’s universal scope (ADR 001) requires us to express these without template duplication or per-workload subcharts.

Helm gives us two natural shapes in values.yaml: a list of objects (each carrying a name) or a map keyed by name. Both can drive a range loop. They differ on how downstream operators read them, how they merge with overlay values, and how easily a human spots a duplicate.

Decision

Workloads of the same kind are declared as keyed maps in values.yaml. The map key is the workload’s logical name and becomes part of the rendered Kubernetes resource name (<release-fullname>-<key>). The chart iterates each map with keys ... | sortAlpha so the rendered output is deterministic.

The three workload maps are:

  • deployments — one Deployment + Service per key.
  • statefulSets — one StatefulSet + headless Service per key.
  • jobGroups — one Job-or-CronJob group per key. See ADR 005.

The same shape is applied to per-workload sub-collections (e.g. service.ports, volumes, volumeMounts, sidecars, initContainers) — see ADR 004 for the broader rule.

Consequences

What this enables:

  • Uniqueness by construction. YAML maps cannot have duplicate keys, so two workloads cannot accidentally share a name within one values file.
  • Targeted overrides in overlay values: setting deployments.api.replicaCount: 3 in a per-environment overlay leaves the rest of deployments.api and all of deployments.worker untouched — Helm’s merge is map-aware. With a list, an overlay would replace the entire list.
  • Stable resource ordering across renders, because the chart sorts keys alphabetically before iterating. Diffs in Argo CD / helm diff stay small and meaningful.
  • Per-workload disabling via the conventional deployments.<name>.enabled: false, without touching neighbouring workloads.

What it costs:

  • The map key must be a valid DNS-1123 subdomain segment (it lands in resource names). The schema enforces this; users cannot put arbitrary characters in the key.
  • “Insertion order” cannot be expressed. When ordering matters (rule precedence in HTTP routes, sidecar startup order), we either fall back to a list (Gateway API rules) or expose an explicit priority field — see ADR 014.
  • The single map iteration model means every workload of a given kind is rendered from one template file. The template must be careful to use $workload scope rather than . when reaching back into $.Values.

Alternatives considered

  • List of objects with name:. Rejected: duplicates silently allowed, overlay merging replaces the whole list, no targeted override.
  • Subchart per workload. Rejected: heavyweight, breaks global.env propagation across “siblings”, and Argo CD multi-source flow becomes awkward.
  • A separate chart per workload kind, composed by a parent chart. Rejected for the same reasons as in ADR 001: drift and review burden.

References

  • values.yaml — the jobGroups, deployments and statefulSets top-level blocks.
  • templates/deployment.yaml, templates/statefulset.yaml, templates/job-groups.yaml, templates/cronjob-groups.yaml — all iterate via range $name, $cfg := .Values.<map> after keys ... | sortAlpha.
  • Related: ADR 004, ADR 014.

003 — Layered inheritance and explicit override

  • Status: Accepted
  • Date: 2025-04-25

Context

A real release fans out across several scopes that all want to inject the same kinds of configuration:

  • the fleet scope — env vars common to every chart instance in a cluster (OTEL_EXPORTER_OTLP_ENDPOINT, region, environment name);
  • the release scope — env vars and labels common to every workload in this app (SERVICE_NAME, app version);
  • the workload scope — env vars specific to one Deployment (“the worker uses a different log level”);
  • the container scope — env vars specific to a sidecar.

If each layer carries its own merge rules implemented inline in templates, the result is fragile: it’s easy to forget to inherit one piece (and pods miss a critical env var) or to double-inject another (and envFrom lists grow). We need one consistent answer that covers env vars, ConfigMap mounts, Secret mounts, volumes, scheduling parameters and labels.

We also need a way to opt out. Most workloads should inherit everything; a small number need to escape — for example, a sidecar that talks to a different secret store than the main container, or a CronJob that should not pick up the global Datadog agent annotation.

Decision

A four-layer cascade with explicit opt-out at the inheriting layer:

global  →  root  →  workload  →  container

At each layer the chart applies these merge rules:

KindHow layers combine
Maps (env vars, labels, annotations)Replace per key — later layer’s value wins on collision; non-colliding keys union.
Lists (envSecrets, envConfigMaps, hostAliases)Concat — earlier-layer entries appear first, later-layer entries appended. Duplicates are not deduplicated; map-keyed inheritance (inherit.configMapMount.<name>: false) is the way to suppress one.
Scheduling fields (tolerations, affinity, nodeSelector, topologySpreadConstraints)Workload replaces root — if the workload defines the field, it takes full ownership and the root value is ignored. If the workload omits the field entirely, the root value is inherited. To explicitly disable root inheritance without providing a replacement, set the field to an empty value (tolerations: [], affinity: {}, topologySpreadConstraints: []).
Volumes (map keyed by volume name)Replace by key — same rule as maps. Cannot deep-merge: a volume data cannot be partially emptyDir and partially configMap. See ADR 004.

The opt-out for env and configMap inheritance is expressed via inherit.* flags:

deployments:
  worker:
    inherit:
      env:
        global: true            # opt out of global.env  → false
        root: true              # opt out of root env    → false
      envSecrets: true          # opt out of root envSecrets in envFrom → false
      configMaps: true          # opt out of root envConfigMaps
      configMapMount: true      # or fine-grained: configMapMount.<name>: false

Default values are all true — i.e. inherit everything. Opt-out is a deliberate, visible setting in the workload that needs it.

For jobGroups, an additional override level applies between a group and one of its jobs — distinct from the layer cascade above and with its own merge rules. Job-level fields override group-level fields as follows: scalars take the job value when set, otherwise the group’s; lists (envSecrets, envConfigMaps, tolerations) are concatenated group-then-job; nested maps (env, image, securityContext, podSecurityContext, resources, nodeSelector, affinity, inherit, hooks.argocd, hooks.helm, metadataAnnotations) deep-merge with the job winning on key collision; volumes and volumeMounts (maps keyed by name) replace whole entries by name — the chart does not recurse into a single volume/mount, because k8s volume kinds are mutually exclusive (a name is either an emptyDir or a configMap, never both). See ADR 005.

Consequences

What this enables:

  • Cluster-wide defaults in global.env propagate everywhere automatically (Helm’s subchart global: mechanism). A platform team can ship an OTel endpoint in one place.
  • Predictable overrides. “Workload value wins for maps; lists concat” is a single sentence to remember.
  • Local escape hatches without touching ancestor configuration. A bursty worker that should not inherit a Datadog agent annotation simply sets inherit.configMapMount.datadog: false.

What it costs:

  • The merge rules differ between maps, lists, and scheduling fields; contributors must remember which is which. We mitigate this with ADR 004 — collections become maps unless they truly cannot — and with the cross-cutting rules section in docs/03-reference/01-values.md.
  • “Inheritance” is not deep merge of arbitrary structures. Volumes, env vars and configMap entries inherit by whole entity keyed by name. This is a feature: deep-merging a volume that’s emptyDir at one layer and configMap at another would produce nonsense.
  • Helpers carrying inheritance logic (uhc.envVars, uhc.envFrom, uhc.containerSpecWithOptions, uhc.sidecarsSpec in templates/_env.tpl and templates/_workload.tpl; uhc.jobGroupSpec in templates/_jobs.tpl) are non-trivial. They are tested aggressively via helm-unittest.

Examples

# global.env propagates to every container in every workload
global:
  env:
    OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318

# Release-level env applies to every workload in this release
env:
  SERVICE_NAME: orders

deployments:
  api:
    # No 'inherit' block — inherits both global and release env
    env:
      LOG_LEVEL: info        # adds to the merged set; wins on collision
  reaper:
    inherit:
      env:
        global: false        # this workload skips global.env
    env:
      LOG_LEVEL: warn

The resulting effective env in deployments.api’s container: OTEL_EXPORTER_OTLP_ENDPOINT, SERVICE_NAME, LOG_LEVEL (plus the chart-injected NAMESPACE from fieldRef).

In deployments.reaper’s container: SERVICE_NAME, LOG_LEVEL only — OTEL_EXPORTER_OTLP_ENDPOINT is suppressed by the opt-out.

Alternatives considered

  • No inheritance — each workload restates everything. Rejected: defeats the universal chart goal and re-creates copy-paste drift inside one chart.
  • Deep merge with strategic-merge-patch semantics. Rejected: surprising behaviour around list merging (replace vs append vs merge-by-key); would require us to re-implement a substantial subset of kubectl apply semantics in templates.
  • Producer-side opt-out — env vars at global.env carrying their own per-workload exclusion list. Rejected: scatters per-workload knowledge into ancestor configuration; harder to read.

References

  • templates/_env.tpl — uhc.envVars, uhc.envFrom. templates/_workload.tpl — uhc.containerSpecWithOptions, uhc.sidecarsSpec, uhc.scheduling. templates/_jobs.tpl — uhc.jobGroupSpec.
  • docs/03-reference/01-values.md — Cross-cutting rules: env merge order, scheduling field inheritance, jobGroups merge.
  • Related: ADR 002, ADR 004, ADR 005.

004 — Maps over lists as the default collection shape

  • Status: Accepted
  • Date: 2025-04-25

Context

Many Kubernetes resource specs are lists where each item carries a name: containers, volumes, volumeMounts, ports, env, imagePullSecrets, Ingress hosts, Service ports. Helm chart values can mirror that list shape directly, or transpose it into a map keyed by the natural-key field. The choice shapes everything users do downstream:

  • Overlay merging. Helm merges values from -f overlays. Maps merge per key; lists are replaced wholesale. Lists therefore force the overlay to redeclare every entry.
  • Duplicate prevention. YAML maps reject duplicate keys at parse time; lists let you have two containers[].name: foo and the kubelet rejects the pod at runtime.
  • Targeted disabling / overriding. A map gives you entry.<name>.enabled: false for free; with a list you have to filter it during template render.
  • Diff stability. A reordered list looks like a meaningful diff to Argo CD; a reordered map round-trips identically because we sort it.

The 2.0.0 refactor pushed as many list-shaped values as possible into maps. Where the map key would be an optional Kubernetes field (e.g. ingress.tls[].secretName), the native list shape was retained or restored to avoid forcing a required value.

Decision

Default to maps. A values.yaml collection becomes a map keyed by its natural unique field whenever such a field exists. The rendered YAML uses the map key as the resource field’s name (volume name, port name, host name, container name, secret name, etc.) — users do not repeat the name inside the value.

A collection stays as a list only when one of these is true:

  1. No natural key. Examples: tolerations (matched by combinations of fields, not name), rbac.rules (a tuple of apiGroups + resources + verbs), Gateway API rules (an ordered list of match-and-forward records), ingress.hosts.<host>.paths (path order matters in some controllers).
  2. Order is part of the contract. Examples: pod initContainers in tasks-mode jobGroups (we sort by name to make order deterministic, but users still see a list-like ordering by alphabetised key); HTTP route precedence (handled with an explicit priority field — see ADR 014).
  3. Duplicates are legitimate. Example: dataFrom in External Secrets, where the same remoteKey can appear with different conversion strategies.
  4. A list of plain strings reads better than a one-key-per-string map. Examples: imagePullSecrets, envSecrets, envConfigMaps, priorityClassName lists, hostAliases.hostnames. The chart wraps each entry into the matching Kubernetes object shape at render time — for example, an imagePullSecrets entry becomes an object with a name field under spec.imagePullSecrets[], and an envSecrets entry becomes a secretRef object under envFrom[].

When a map is used, the chart always iterates with keys ... | sortAlpha so output order is deterministic regardless of values.yaml author’s keystroke order. Special cases (port name ordering, init container ordering) live in ADR 014.

Consequences

What this enables:

  • Overlay values can target one entry without rewriting the rest:
    # base.yaml
    deployments:
      api:
        service:
          ports:
            http:
              port: 80
              targetPort: 8080
            metrics:
              port: 9090
              targetPort: 9090
    
    # prod-overlay.yaml — bumps the metrics port only
    deployments:
      api:
        service:
          ports:
            metrics:
              port: 9091
    
  • Duplicate volume names, port names and the like are caught by the YAML parser before the chart even runs.
  • Per-entry disabling reads naturally: configMaps.app-config.enabled: false.
  • Helper code is consistent: every map-iterating loop reuses the same keys ... | sortAlpha pattern.

What it costs:

  • Migrating a list to a map is a breaking change to the values.yaml API. The 2.0.0 release line absorbs this cost; future migrations will need ADRs of their own.
  • Map keys collide with reserved identifier rules: a port name must be ≤15 chars (RFC 6335), a host must be a valid DNS name. values.schema.json enforces these at chart-install time. See ADR 013.
  • Some users prefer the list shape because it visually resembles raw Kubernetes manifests. We accept the small re-learning cost in exchange for the merge / dedup / dedupe properties above.

Migration philosophy (2.0.0)

The pre-2.0.0 chart had a mix of list and map shapes for similar concepts. The 2.0.0 line landed three rounds of consolidation:

  • monitoring providers became a defaults-driven matrix (incidental side-effect: trimmed lists in scrape config).
  • The integrations namespace was introduced (incidental side-effect: ecosystem knobs that used to be top-level lists became maps under integrations.*).
  • The remaining lists were explicitly collapsed into maps where a natural key existed: volumes, volumeMounts, service.ports, ports, ingress.hosts, httpRoutes, referenceGrants, configMaps, sidecars. Lists kept: imagePullSecrets, envSecrets, envConfigMaps (now lists of plain strings), ingress.tls (native k8s list — secretName is optional in k8s so using it as a map key would make it falsely mandatory), Gateway API rules, rbac.rules, tolerations, hostAliases, ESO dataFrom.

Alternatives considered

  • Keep everything as a list (mirror Kubernetes API verbatim). Rejected: gives up overlay-merge wins, duplicate prevention, and per-entry disabling.
  • Keep everything as a map (no lists at all). Rejected: the four exceptions above are real — a deduplicated map representation of tolerations or Gateway API rules either misrepresents the upstream type or invents synthetic keys.
  • Custom DSL on top of values. Rejected: more to learn; less inspectable.

References

  • values.yaml carries no inline comments by design — documentation lives in docs/ and values.schema.json.
  • Related: ADR 002, ADR 013, ADR 014.

005 — jobGroups unifying Job and CronJob

  • Status: Accepted (replaces previous separate jobs / cronJobs blocks)
  • Date: 2025-04-25

Context

Database migrations, scheduled cleanup tasks, ad-hoc backfills and smoke checks share most of their configuration: the same image, the same env, the same secrets, the same scheduling parameters. What they differ on is mostly the trigger — a Job runs once on deploy; a CronJob runs on a schedule.

Before 2.0.0 the chart had separate jobs: and cronJobs: top-level blocks. The duplication produced two bad outcomes:

  1. Real-world groups of related jobs (a migrate Job and a seed Job that both want the same image, env vars and volumes) had to repeat all the shared fields. Eventually one of the copies drifted.
  2. The “Job vs CronJob” decision is sometimes made late — e.g. a smoke check starts as a Job and becomes a periodic check. Moving across the boundary required manual edits in two places.

Tasks-mode (multiple sequenced steps in one Job using initContainers) added another twist: a single logical “thing the team runs” became a group of related jobs sharing context.

Decision

Unify under a single top-level map: jobGroups. Each group has:

  • a kind field — Job or CronJob (set on the group, not per-job; mixed-kind groups are deliberately not supported);
  • shared configuration — image, env, envSecrets, envConfigMaps, hooks, volumes, volumeMounts, tolerations, security context, resources, etc.;
  • a nested jobs: map where each entry becomes one Job/CronJob resource and may override any group-level field.

The merge between group and per-job is one level deeper than the layer cascade in ADR 003, and the rules are field-shape specific:

Field shapeExamplesMerge rule
Atomic / replace-wholebackoffLimit, schedule, restartPolicy, command (string-or-array), args, tasks (whole map), ttlSecondsAfterFinished, concurrencyPolicyJob value wins when set; otherwise the group value (and otherwise nothing). Composite values like command and tasks are treated as opaque — the job’s value replaces the group’s wholesale, no recursion
ListenvSecrets, envConfigMaps, tolerationsConcat group entries first, then job entries
Nested mapenv, image, securityContext, podSecurityContext, resources, nodeSelector, affinity, inherit, metadataAnnotations, hooks.argocd, hooks.helmDeep-merge: keys present in the job replace the group’s value at that key, keys present only on the group are preserved
Name-keyed mapvolumes, volumeMountsPer-entry replace by name: a job’s volumes.data replaces the group’s volumes.data whole; non-colliding keys from both layers coexist. The chart does not deep-merge into a single volume/mount because k8s volume kinds are mutually exclusive (a name is either emptyDir or configMap, never both)
kindJob / CronJobGroup only — per-job override is intentionally rejected
podAnnotations—Effective merge of root → root.jobPodAnnotations → group → job (last wins)

This is implemented in templates/_jobs.tpl:uhc.jobGroupSpec and documented in the worked example next to the jobGroups block in values.yaml.

Resource name: <release-fullname>-<group[:8]>-<job>[-<sha8>]. The sha8 suffix is governed by ADR 012.

tasks (a map of initContainers) is mutually exclusive with single-container mode (command / args). The schema enforces this — a job with both is rejected. Task entries have no enabled flag — every key present in the tasks map is rendered as an initContainer.

Task naming convention. Kubernetes runs initContainers sequentially in the order they appear in spec.initContainers[]. The chart sorts the tasks map alphabetically before emitting (see ADR 014), so the alphabetical order of task names is the order in which they actually run. When the order matters, prefix task names with 01-, 02-, 03- etc. — that’s the convention used in the worked example below and in 02-examples/05-cronjobs/.

Consequences

What this enables:

  • A migration job and a seed job declared as siblings under one group share image, env, volumes — with concise, override-only difference at the per-job level.
  • A logical task crossing the Job ↔ CronJob boundary is a one-line edit (kind:).
  • The hash-suffix idempotency model (ADR 012) operates uniformly on both kinds.
  • Argo CD / Helm hook annotations (hooks.argocd.*, hooks.helm.*) live on the group with per-job override — natural for “all jobs in this group are pre-install hooks”. When hooks.argocd.hook is set, deletePolicy defaults automatically to HookFailed (succeeded jobs stay for idempotent re-sync; failed jobs are deleted so the next sync retries them). Set deletePolicy explicitly to override this default.

What it costs:

  • Mixed Job + CronJob in one group is rejected. Users with such a need declare two groups. In practice this is a non-issue: a migration Job and a nightly cleanup CronJob have nothing in common worth sharing.
  • Per-job kind overrides are not allowed. Same rationale.
  • Tasks mode and single-container mode are mutually exclusive — schema-enforced. A user wanting both must split into two jobs.
  • The merge code (uhc.jobGroupSpec) is the most complex helper in the chart. It is covered by tests/job_groups_test.yaml and tests/cronjob_groups_test.yaml, which together hold the largest single-suite test count in the repo.

Examples

jobGroups:
  db:
    kind: Job
    image:
      repository: my/migrator
      tag: v1
    env:
      LOG_LEVEL:
        value: info
    envSecrets:
      - db-creds
    hooks:
      argocd:
        hook: PreSync
        syncWave: "-2"
    jobs:
      migrate:
        # tasks-mode: each task becomes an initContainer.
        # Tasks run in the alphabetical order of their map keys — prefix names
        # with 01-, 02-, ... when ordering matters (Kubernetes runs initContainers
        # sequentially).
        tasks:
          01-schema:
            command: |
              psql -c "CREATE TABLE IF NOT EXISTS schema_version (id SERIAL PRIMARY KEY)"
          02-seed:
            args:
              - seed
              - --rows=100
      smoke-check:
        # single-container mode: one shot
        command: wget -q -O- http://api/health || exit 1
        backoffLimit: 1
        hooks:
          argocd:
            syncWave: "3"   # overrides group default
  nightly:
    kind: CronJob
    image:
      repository: my/cleaner
      tag: v1
    jobs:
      cleanup:
        schedule: "0 2 * * *"
        timeZone: UTC
        concurrencyPolicy: Forbid

Alternatives considered

  • Two top-level blocks jobs and cronJobs. Rejected: the pre-2.0.0 status quo. Encouraged drift between sibling jobs and made Job→CronJob conversion painful.
  • Per-job kind field. Rejected: a CronJob and a Job in the same group share almost nothing operationally (hooks differ, schedule semantics differ, completion behaviour differs). Worth the inconvenience to keep groups homogeneous.
  • Flatten — every Job/CronJob is its own top-level entry. Rejected: would re-introduce the duplication that motivated grouping.

References

  • templates/_jobs.tpl — uhc.jobGroupSpec, uhc.jobGroupHash, uhc.jobGroupHookAnnotations.
  • docs/02-examples/05-cronjobs/ — worked example.
  • templates/job-groups.yaml, templates/cronjob-groups.yaml.
  • Tests: tests/job_groups_test.yaml, tests/cronjob_groups_test.yaml.
  • Related: ADR 003, ADR 012.

006 — integrations namespace for ecosystem knobs

  • Status: Accepted
  • Date: 2025-04-25

Context

The chart sits in an ecosystem of controllers that consume or annotate workload resources: Argo CD (sync waves, ImageUpdater), Stakater Reloader (auto-restart on ConfigMap/Secret change), External Secrets Operator (ESO — SecretStore, ExternalSecret), Prometheus Operator and the VictoriaMetrics Operator (scrape CRDs), KEDA (event-driven autoscaling).

Each integration introduces a couple of values keys: a master switch, some configuration, and CRD-shaped sub-resources. Pre-2.0.0 these accumulated at the top level of values.yaml: argocdSyncWaves: …, reloader: enabled, secretStores: …, externalSecrets: …, serviceMonitorEnabled: …. The result was a ~50-key-deep root, where unrelated controllers’ switches lived next to each other and there was no common shape for “is this integration on?”

Decision

Group all ecosystem knobs under a single top-level integrations: block, with one sub-section per integration. Every integration provides a master enabled switch, configuration fields, and (where relevant) CRD-shaped resources.

integrations:
  argocd:
    syncWaves:
      enabled: true                       # see ADR 010 for the default wave map
    imageUpdater:
      enabled: false                      # see values.yaml for full Image Updater config
  stakater:
    reloader:
      enabled: false                      # see ADR 017
  eso:
    enabled: true
    secretStores: {}                      # map; see ADR 015
    externalSecrets: {}                   # map; see ADR 015
  monitoring:
    defaults:
      enabled: false                      # see ADR 008 / ADR 016

The customize.labels and customize.annotations sub-blocks under each integration’s resources allow users to inject extra metadata onto the resources that integration owns (e.g. add a Reloader strategy annotation onto a ConfigMap). This is the same shape across integrations.

Consequences

What this enables:

  • Discoverability. A reader scanning values.yaml sees one block listing every external system the chart knows about. Anything outside this block is “core Kubernetes only”.
  • Uniform on/off semantics. integrations.eso.enabled: false short-circuits all ESO rendering even if secretStores or externalSecrets entries are declared — useful for “park the configuration” in environments where ESO is not installed.
  • Adding a new integration is a contained change. A new sub-section under integrations plus the templates that consume it; no top-level churn.
  • Schema clarity. Each integration’s schema is one named object — easy to validate strictly. See ADR 013.

What it costs:

  • The values paths are deeper: integrations.monitoring.defaults.provider instead of monitoringProvider. Overlay values verbose ratio goes up. We accept this in exchange for the discoverability benefit.
  • Renaming was a 2.0.0 breaking change. Pre-2.0.0 paths do not work; this is captured in the chart version bump.

Alternatives considered

  • Top-level keys per integration (reloader: …, eso: …, monitoring: …). Rejected: pre-2.0.0 status quo. Top-level becomes noisy and there’s no obvious place for new integrations.
  • A single top-level enabled block listing per-integration toggles, with each integration’s config under a separate top-level key. Rejected: separates the on/off switch from the config, making it possible to enable an integration with no config and vice versa.
  • Integration-specific sub-charts. Rejected: would re-create the dependency-graph fragility we avoid by having one chart (ADR 001).

References

007 — Autoscaler mutual exclusion (HPA / KEDA / VPA)

  • Status: Accepted
  • Date: 2025-04-25

Context

Three different controllers can mutate a workload’s runtime sizing:

  • HPA (autoscaling/v2 HorizontalPodAutoscaler) — scales spec.replicas based on CPU / memory / custom metrics.
  • KEDA (keda.sh ScaledObject) — also scales spec.replicas, driven by event sources (Kafka lag, queue depth, cron, …). KEDA creates an HPA under the covers.
  • VPA (autoscaling.k8s.io VerticalPodAutoscaler) — adjusts containers[].resources.requests (and optionally limits) based on observed usage.

The chart exposes per-workload knobs for all three. Without explicit constraints, a values file could enable both HPA and KEDA on the same Deployment — two HPAs (the user’s plus KEDA’s) fight over spec.replicas and the result is undefined oscillation. Less catastrophically, a Deployment with HPA enabled but spec.replicas set to a fixed number creates a one-shot conflict on every Helm apply: HPA scales away from the value, the next helm upgrade writes it back.

Decision

  • HPA and KEDA are mutually exclusive per workload. The chart enforces this at template render: enabling KEDA’s ScaledObject for a workload that also has HPA enabled is a fail. Users pick one.
  • VPA is orthogonal to both. VPA touches resources.requests; HPA / KEDA touch replicas. They can co-exist on the same workload, though running VPA in Auto mode together with HPA on CPU/memory targets is rarely a good idea.
  • spec.replicas is omitted from the rendered workload when HPA or KEDA is enabled. This prevents the per-deploy fight described above. Without an autoscaler, the chart writes the user-supplied (or default) replica count.

The check lives in templates/hpa.yaml (HPA suppression when KEDA is on) and in the spec.replicas rendering of templates/deployment.yaml and templates/statefulset.yaml (omitted when an HPA-class scaler is active).

Consequences

What this enables:

  • A single values switch decides “auto-scale on metrics” (hpa.enabled: true) vs “auto-scale on events” (keda.enabled: true). Users cannot accidentally turn on both.
  • VPA can be enabled on every workload to recommend (or in Recreate mode, set) right-sized resource requests, regardless of horizontal scaling.
  • A redeploy with HPA on does not bounce the replica count — Helm doesn’t write replicas, so HPA’s last decision sticks.

What it costs:

  • Users wanting “manual replicas overridden by HPA only on traffic spikes” must opt out of the chart’s “HPA on → no replicas in spec” behaviour — they would set replicaCount for the very first apply, then turn HPA on. The chart does not support a hybrid mode.
  • The fail on HPA + KEDA simultaneously is loud. The error message names both keys so users can fix it without reading the source.

Defaults

All three are off by default. This is per-workload too — hpa.enabled, keda.enabled, verticalPodAutoscaler.enabled each default to false at root and may be overridden per deployments.<name> or statefulSets.<name>.

Alternatives considered

  • Allow HPA + KEDA together. Rejected: KEDA already manages an HPA; a user-supplied second HPA is double scaling.
  • Choose for the user (e.g. KEDA wins if both are on). Rejected: silent precedence is harder to debug than a fail.
  • Treat VPA as also mutually exclusive with HPA. Rejected: too restrictive; the well-known caveat (don’t VPA-manage CPU when HPA scales on CPU) is documentable. In the most common case (VPA updateMode: Initial for memory + HPA on CPU) the combination is safe and useful.

References

  • values.yaml — the verticalPodAutoscaler block (chart-wide VPA defaults) and per-workload hpa / keda / verticalPodAutoscaler entries.
  • templates/hpa.yaml, templates/scaledobject.yaml, templates/verticalpodautoscaler.yaml.
  • templates/deployment.yaml, templates/statefulset.yaml — spec.replicas is omitted when an HPA-class scaler is enabled.
  • tests/deployment_test.yaml — replica-omission cases.
  • tests/hpa_test.yaml and tests/scaledobject_test.yaml — failedTemplate assertions that pin the HPA + KEDA fail-fast guard.

008 — Multi-provider monitoring matrix

  • Status: Accepted
  • Date: 2025-04-25

Context

Two Prometheus-compatible monitoring stacks dominate Kubernetes deployments:

  • Prometheus Operator — exposes monitoring.coreos.com/v1 ServiceMonitor and PodMonitor CRDs.
  • VictoriaMetrics Operator — exposes operator.victoriametrics.com/v1beta1 VMServiceScrape and VMPodScrape CRDs (intentionally compatible shape, different API group).

In addition, both stacks can also discover scrape targets via annotations on the Service or Pod (prometheus.io/scrape, prometheus.io/port, prometheus.io/path) when the Prometheus / vmagent server is configured with kubernetes_sd_configs and the right relabel rules. Annotation-mode discovery doesn’t need a CRD — a useful escape hatch in clusters that haven’t installed an operator.

Finally, a workload may want its scrape target on the Service (for Deployments and StatefulSets with a stable Service) or on the Pod (for jobs that don’t have a Service, or workloads where pod-level addressing is preferred).

We need one mental model that covers two providers × two discovery modes × two target shapes = eight combinations, with sensible defaults and per-workload override.

Decision

A three-axis matrix, configured at integrations.monitoring.defaults and overridable per workload at deployments.<name>.metrics.* (or statefulSets.<name>.metrics.*):

AxisValuesDefault
providerprometheus | victoriametricsprometheus
discoverycrd | annotationscrd
typeservice | podservice

What each combination renders:

  • prometheus × crd × service → ServiceMonitor
  • prometheus × crd × pod → PodMonitor
  • victoriametrics × crd × service → VMServiceScrape
  • victoriametrics × crd × pod → VMPodScrape
  • * × annotations × service → prometheus.io/* annotations on the Service
  • * × annotations × pod → prometheus.io/* annotations on the Pod template

Defaults at integrations.monitoring.defaults apply to every workload; per-workload values at deployments.<name>.metrics.* always win. The enabled switch defaults to false (workloads must opt in) — explicit opt-in keeps unscraped workloads off the monitoring radar by default.

Auxiliary fields (port, path, scheme, interval, scrapeTimeout, relabelConfigs, basicAuth, headers) are common to all combinations and override per-workload too.

Consequences

What this enables:

  • One chart works in clusters running Prometheus Operator, VictoriaMetrics, both, or neither (annotations mode).
  • A team migrating from Prometheus to VictoriaMetrics flips one value (provider: victoriametrics) for the whole release without rewriting CRDs.
  • Per-workload override gives one app two scrape configs if needed (e.g. main port via CRD, sidecar port via annotations on the Pod).

What it costs:

  • Annotation-mode discovery requires prometheus.io/port to be numeric (the annotation contract). Setting metrics.port to a port name (which crd mode requires) is rejected by the schema in annotations mode. The chart applies a fallback: if metrics.port is unset in annotations mode, it uses service.targetPort or omits the annotation entirely. See the integrations.monitoring.defaults.discovery comment in values.yaml for the full narrative.
  • The CRD-mode port field expects a port name, not a number. Users hitting this for the first time get a CRD validation error; the schema documents the constraint at values.schema.json (search for discovery).
  • Two CRD groups must be tracked for compatibility (monitoring.coreos.com and operator.victoriametrics.com). 03-reference/03-compatibility.md lists the tested versions.

Default behaviour

Defaults aim for the common case: Prometheus Operator-based cluster, scrape via CRD, target the Service. A typical workload only needs:

deployments:
  api:
    service:
      ports:
        http:
          port: 80
          targetPort: 8080
        metrics:
          port: 9090
          targetPort: 9090
    metrics:
      enabled: true
      port: metrics       # name from service.ports above

Alternatives considered

  • Prometheus only. Rejected: VictoriaMetrics is widely deployed for cost / retention reasons.
  • Annotations only. Rejected: CRD-driven discovery is the modern default; annotation-mode is a compatibility option.
  • A single monitoring.kind enum (ServiceMonitor / PodMonitor / VMServiceScrape / VMPodScrape / annotations-service / annotations-pod). Rejected: hides the orthogonal axes; harder to override one without restating the others.

References

  • values.yaml — the integrations.monitoring.defaults block.
  • templates/servicemonitor.yaml, templates/podmonitor.yaml.
  • templates/_metrics.tpl — uhc.metricsProvider, uhc.metricsDiscovery, uhc.metricsExposeService, uhc.metricsAnnotations, uhc.scrapeEndpoint, uhc.metricsEnabled, uhc.serviceWorkloads.
  • Tests: tests/servicemonitor_test.yaml, tests/podmonitor_test.yaml, tests/metrics_annotations_test.yaml.
  • Related: ADR 006, ADR 016.

009 — Dual networking stack (Ingress + Gateway API)

  • Status: Accepted
  • Date: 2025-04-25

Context

Two layers of routing primitives are widely deployed in 2025:

  • Ingress (networking.k8s.io/v1) — stable, ubiquitous, controller-specific feature surface (annotations carry the meaning).
  • Gateway API (gateway.networking.k8s.io/v1) — newer, expressive, multi-route-kind: HTTPRoute, GRPCRoute, TLSRoute, plus ReferenceGrant for cross-namespace backends.

Real fleets have both: legacy services on Ingress, new services on Gateway API, and migration paths in between. A chart that supports only one forces a hard cutover; a chart that supports both lets each service migrate on its own schedule.

The chart also needs to handle the two reasonable mental models for routing:

  • Per-release routing object — one Ingress / HTTPRoute per release, every workload contributes rules to it.
  • Per-workload routing object — each workload owns its own Ingress / HTTPRoute (different controllers, different hostnames, different parents).

Decision

Support both Ingress and Gateway API, with both singleton and multi-resource shapes for each:

ResourceSingleton keyMulti-resource mapTemplates
Ingressingress.*ingresses.*templates/ingress.yaml
HTTPRoutehttpRoute.*httpRoutes.*templates/httproute.yaml
GRPCRoutegrpcRoute.*grpcRoutes.*templates/grpcroute.yaml
TLSRoutetlsRoute.*tlsRoutes.*templates/tlsroute.yaml
ReferenceGrantreferenceGrant.*referenceGrants.*templates/referencegrant.yaml

Per-workload route shorthand. When a workload at deployments.<w>.httpRoute.enabled: true is set, its rules merge into the singleton httpRoute (or grpcRoute / tlsRoute). parentRefs and hostnames are inherited from the singleton; serviceName and servicePort default to the workload’s Service. A priority integer controls rule order in the merged list (lower = earlier; ties broken alphabetically by workload name). The same shorthand works for grpcRoute and tlsRoute.

HTTPRoute rule-level fields. Each rule in httpRoute.rules (root, per-workload, or plural-map) supports four optional field groups from the Gateway API spec: name (identifies the rule in status and traces), timeouts (request and backendRequest as Gateway API Duration strings — GEP-2257), retry (attempts, codes status-code list 400–599, backoff), and sessionPersistence (sessionName, type (Cookie/Header), absoluteTimeout, idleTimeout, cookieConfig.lifetimeType). All four are independently optional and are omitted from the rendered manifest when not set. Channel/version requirements: name needs Gateway API v1.4+ Standard or v1.2+ Experimental CRDs; timeouts is Standard since v1.2; retry and sessionPersistence are Experimental only as of Gateway API v1.5.1. The chart fails fast when a field is set but the cluster’s installed CRD channel does not support it (detection skipped during offline helm template).

HTTPRoute rule schema strictness. Each entry in httpRoute.rules (and the equivalent under httpRoutes.<n>.rules and per-workload route maps) is validated against a strict schema (additionalProperties: false) listing only the supported fields: name, matches, filters, backendRefs, serviceName, servicePort, timeouts, retry, sessionPersistence. Unknown keys — typos, deprecated fields, or Gateway API fields not yet wired into the chart — now fail at helm template time instead of being silently dropped. This is a behaviour change relative to the previous loose type: array validation on httpRoute.rules; if you depend on a Gateway API HTTPRouteRule field not in the list above, file an issue or submit a PR.

GRPCRoute rule-level fields. Each rule in grpcRoute.rules (root, per-workload, or plural-map) supports name (Gateway API SectionName, identifies the rule in status and traces) and sessionPersistence (sessionName, type (Cookie/Header), absoluteTimeout, idleTimeout, cookieConfig.lifetimeType) on top of the existing matches/filters/backendRefs. Both are independently optional. The Gateway API spec does not define timeouts or retry on GRPCRouteRule — those are HTTPRoute-only — so the chart does not surface them. Channel/version requirements: name needs Gateway API v1.4+ Standard or v1.2+ Experimental CRDs; sessionPersistence is Experimental only as of Gateway API v1.5.1. The chart fails fast at template time when a field is set but the cluster’s installed CRD channel does not support it (detection skipped during offline helm template). The same strict per-rule schema (additionalProperties: false) now applies to GRPCRoute rules — unknown keys fail fast.

TLSRoute rule-level fields. Each rule in tlsRoute.rules (root, per-workload, or plural-map) supports name (Gateway API SectionName, identifies the rule) on top of backendRefs. The Gateway API spec defines only name and backendRefs on TLSRouteRule — TLS is L4 passthrough, so matches, filters, timeouts, retry, and sessionPersistence are not part of the type and are not surfaced by the chart. TLSRoute itself is Experimental-only (gateway.networking.k8s.io/v1alpha2), so any use of TLSRoute already presupposes Experimental CRDs — name adds no further per-field channel requirement. The same strict per-rule schema (additionalProperties: false) now applies to TLSRoute rules.

ReferenceGrant lives in the namespace of the target (the Service being referenced), so the chart always emits it in the release namespace. It supports cross-namespace HTTPRoute → Service references without RBAC complexity.

Singleton vs multi-resource. The singleton exists for the common case (one chart instance, one external entry point). The multi-map exists when the same release exposes routes via different controllers / parents / hostnames (e.g. nginx-internal Ingress for east-west traffic, nginx-external Ingress for north-south).

Consequences

What this enables:

  • A team migrating from Ingress to HTTPRoute can run both side-by-side during the migration window — ingress.enabled: true and httpRoute.enabled: true co-exist on the same release.
  • “Internal + external” Ingresses without a second chart release.
  • Cross-namespace Gateway API routing without per-release ReferenceGrant boilerplate — drop a referenceGrants.from-gateway-ns map entry and the resource is generated.
  • Per-workload route rules contributed automatically to the right merged route, with deterministic ordering.

What it costs:

  • Five route templates plus two Ingress shapes (singleton + map) is a lot of surface. Tests cover each (tests/httproute_test.yaml, tests/grpcroute_test.yaml, tests/tlsroute_test.yaml, tests/ingress_test.yaml, tests/referencegrant_test.yaml).
  • The “rules merge from per-workload shorthand” model needs a clear ordering contract. Implemented as: explicit priority first (ascending), then alphabetical by workload name. See ADR 014.
  • A user can configure both ingress and ingresses (singleton + map) at the same time. The chart renders both — duplicates are the user’s problem. This is an intentional simplification; a fail-fast check would over-constrain unusual but valid setups (singleton for legacy, map for new).
  • Gateway API requires CRDs installed in the cluster. The chart assumes installs match the user’s intent: if httpRoute.enabled: true is set without the CRD installed, kubectl apply fails with a clear message.

Per-workload shorthand example

httpRoute:
  enabled: true
  parentRefs:
    - name: my-gateway
      namespace: gateway-system
  hostnames:
    - app.example.com

deployments:
  api:
    service:
      ports:
        http:
          port: 80
          targetPort: 8080
    httpRoute:
      enabled: true
      priority: 10
      rules:
        - name: api-traffic
          matches:
            - path:
                type: PathPrefix
                value: /api
          timeouts:
            request: 30s
            backendRequest: 10s
          retry:
            attempts: 3
            codes: [503, 504]
            backoff: 1s
  web:
    service:
      ports:
        http:
          port: 80
          targetPort: 80
    httpRoute:
      enabled: true
      priority: 20
      rules:
        - matches:
            - path:
                type: PathPrefix
                value: /

Renders one HTTPRoute with two merged rules: API first (with name, timeouts, retry), web second (minimal catch-all). serviceName defaults to <release-fullname>-api / -web; servicePort defaults to the workload’s service.port.

Alternatives considered

  • Ingress only. Rejected: closes the door on Gateway API migrations.
  • Gateway API only. Rejected: many clusters still don’t have a Gateway controller installed; Ingress remains the lowest-common-denominator routing primitive.
  • Per-workload Ingress, no singleton. Rejected: the common case is one Ingress per release; making users pay multi-resource ceremony for the common case is a regression.
  • Render parentRefs / hostnames on every per-workload route. Rejected: invites drift; one source of truth (singleton) and inheritance is cleaner.

References

  • values.yaml — the ingress / ingresses and Gateway API (httpRoute / httpRoutes / grpcRoute / grpcRoutes / tlsRoute / tlsRoutes / referenceGrant / referenceGrants) blocks.
  • templates/ingress.yaml, templates/httproute.yaml, templates/grpcroute.yaml, templates/tlsroute.yaml, templates/referencegrant.yaml.
  • Tests: tests/ingress_test.yaml, tests/httproute_test.yaml, tests/grpcroute_test.yaml, tests/tlsroute_test.yaml, tests/referencegrant_test.yaml.
  • Related: ADR 014.

010 — Argo CD sync-waves baked in by default

  • Status: Accepted
  • Date: 2025-04-25

Context

Argo CD applies all manifests in a sync simultaneously by default, then waits for them to reconcile. For most charts that’s fine. For a chart that emits a Deployment, an ExternalSecret feeding it, a ServiceAccount the Deployment runs as, an Ingress pointing at its Service, and a ServiceMonitor watching it, “all at once” produces noisy first-time syncs:

  • The Deployment’s pod fails to start because the ExternalSecret hasn’t materialised the Secret yet.
  • The Ingress is created before the Service backing it.
  • The ServiceMonitor references a Service that hasn’t been admitted.

These resolve themselves on retry, but the sync looks unhealthy for the first few seconds and the noise pollutes Argo CD’s “out of sync” / “degraded” indicators.

Argo CD’s mechanism for this is sync waves — argocd.argoproj.io/sync-wave: "<n>" annotations on each resource. Lower waves apply first; the controller waits for each wave to be Healthy before starting the next. The chart can either expect users to set waves themselves (rare — most won’t) or supply sane defaults.

Decision

Bake sane sync-wave defaults into the chart, controlled centrally at integrations.argocd.syncWaves. Each resource kind has a default wave; users can override any number, disable any one (set to null to omit only that kind’s annotation), or disable the whole feature (syncWaves.enabled: false).

Default wave assignment, with gaps of 10 for user-injected resources:

ServiceAccount / RBAC          -30
SecretStore                    -20
ExternalSecret / ConfigMap     -10
Service / ImageUpdater           0
StatefulSet                     10
Deployment                      20
HPA / VPA / PDB / ScaledObject /
  ServiceMonitor / PodMonitor   21
NetworkPolicy / ReferenceGrant  25
Ingress / HTTPRoute /
  GRPCRoute / TLSRoute          30

Rationale for the order:

  • SA / RBAC at the back so workloads have an identity to run as.
  • SecretStore before ExternalSecret (ExternalSecret references it).
  • ExternalSecret and ConfigMap before workloads (env / volume mounts depend on them).
  • Service before workloads’ ports actually resolve (small but real win for ServiceMonitor at +21).
  • StatefulSet before Deployment is mostly cosmetic — they don’t depend on each other — but having a fixed order keeps Argo CD’s “applying wave 10/wave 20” output stable across releases.
  • Derived resources (HPA, VPA, PDB, ScaledObject, monitors) at +21, immediately after the workload they reference.
  • NetworkPolicy / ReferenceGrant at +25 — they reference Services and workloads.
  • Ingress / Gateway API routes at +30, last — they reference Services that need to exist.

jobGroups opts out of the chart-level default. Job hooks declare their own waves at jobGroups.<g>.hooks.argocd.syncWave (or per job), because hook ordering is a per-job decision (a migration runs at -10, a smoke check runs at +10) and a chart-wide default would be wrong for at least half of cases.

Consequences

What this enables:

  • Clean first-time syncs in Argo CD: each wave finishes Healthy before the next starts. No false “Degraded” flicker on a fresh install.
  • Users who don’t run Argo CD pay nothing — the annotations are inert outside Argo CD.
  • Per-resource override: integrations.argocd.syncWaves.ingress: 50 if you want Ingress especially late.
  • Per-resource opt-out: integrations.argocd.syncWaves.serviceMonitor: null to omit that one annotation, useful when a different controller manages monitor objects.

What it costs:

  • The defaults are opinionated. Teams that already use sync waves with their own conventions will need to override the whole map.
  • The annotation is added to every resource the chart emits, even in non-Argo-CD environments. It’s harmless, but a few bytes per object in kubectl get … -o yaml.
  • Adding a new resource kind to the chart requires adding it to syncWaves with a sensible number — not just a template change.

Override examples

# Disable the feature entirely
integrations:
  argocd:
    syncWaves:
      enabled: false

# Push Ingress later (after a custom CRD applied at +35)
integrations:
  argocd:
    syncWaves:
      ingress: 40

# Drop the annotation only for ServiceMonitor (a controller writes its own)
integrations:
  argocd:
    syncWaves:
      serviceMonitor: null

Alternatives considered

  • No defaults; users set waves themselves. Rejected: the common case (a fresh install) flickers Degraded for no good reason. Most users will never set waves; the chart should give them the right answer for free.
  • Helm post-install / post-upgrade hooks instead. Rejected: chart-wide hook annotations on workloads are heavyweight and break Argo CD’s incremental sync model.
  • Hard-code the waves; no override. Rejected: closes the door on teams with existing conventions.

References

  • values.yaml — the integrations.argocd.syncWaves block.
  • templates/_annotations.tpl — uhc.metadataAnnotations (composes the wave annotation), uhc.syncWaveAnnotation (resolves the per-kind value).
  • All resource templates — each calls uhc.metadataAnnotations for its kind.
  • Argo CD docs: https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/.
  • Related: ADR 005 (job hook waves), ADR 006.

011 — Standard-wins labels and invariant workload selectors

  • Status: Accepted
  • Date: 2025-04-25

Context

Two label requirements apply to every Kubernetes workload:

  1. spec.selector.matchLabels must match a subset of spec.template.metadata.labels. The selector is immutable for Deployment / StatefulSet / Job. Mutating it is rejected by the API server. Drift here breaks upgrades.
  2. Cluster policies (Kyverno / OPA-Gatekeeper / built-in admission) increasingly require labels like app.kubernetes.io/name, app.kubernetes.io/instance, plus organisation-specific ones like cost-center, team, environment.

Two label sources converge on each resource the chart renders:

  • Chart-managed labels — the app.kubernetes.io/* set the chart computes (name, instance, version, managed-by, part-of, component).
  • User-supplied labels — commonLabels plus per-feature annotation/label customisation under integrations.<name>.customize.labels.

Without explicit rules, two failure modes are easy to hit:

  • A user setting commonLabels.app.kubernetes.io/instance: my-thing overrides the chart’s selector instance label, and now the Deployment’s selector points at pods that don’t exist.
  • A label-policy admission flag (labels.standard.enabled: false) intended to suppress noisy app.kubernetes.io/version on singleton resources accidentally suppresses the selector labels too — pods orphaned.

Decision

Two rules, applied uniformly:

Rule 1: Standard-wins on app.kubernetes.io/*

When commonLabels (or any per-feature label customisation) declares a key in the app.kubernetes.io/* namespace that the chart also computes, the chart’s value wins. The user’s value is silently dropped.

Implementation: templates/_labels.tpl:uhc.labels — chart labels come last in the merge, so they overwrite. Documented next to the commonLabels / commonAnnotations / labels.standard blocks in values.yaml.

Rule 2: Invariant selectors on workloads

Workload resources — Deployment, StatefulSet, Service, Job, CronJob (its Job template), HPA, VPA, PDB, ScaledObject, ServiceMonitor, PodMonitor, ESO ExternalSecret’s target metadata — always emit app.kubernetes.io/name and app.kubernetes.io/instance in spec.selector.matchLabels (and matching pod template labels), regardless of any toggle.

The labels.standard.* toggles (enabled, name, instance, version, managedBy, partOf) only apply to singleton resources — ServiceAccount, Ingress, *Routes, ReferenceGrant, NetworkPolicy, ImageUpdater, RBAC, top-level ConfigMap. On workloads, name and instance are forced on; the other three (partOf, version, managedBy) follow their toggles.

Implementation: templates/_labels.tpl:uhc.workloadSelectorLabels always emits name + instance, called from every workload template’s spec.selector.matchLabels and pod template metadata.labels.

Consequences

What this enables:

  • Users can set fleet-wide commonLabels (e.g. cost-center: data-platform) without ever risking selector breakage.
  • A cluster with a strict label-policy admission can disable noisy standard labels on singletons (labels.standard.enabled: false) without breaking workloads.
  • The chart’s selector contract is a single sentence: “name and instance are always there, everything else is configurable.”

What it costs:

  • Users who genuinely want to override app.kubernetes.io/instance (typical in clusters with weird Helm release naming conventions) must use nameOverride / fullnameOverride instead. The chart documents this.
  • The “standard-wins” rule is a silent suppression — users may set a value and not see it in the rendered manifest. We accept this in exchange for the safety guarantee. The schema cannot help here (it doesn’t know which keys collide).

Examples

commonLabels:
  cost-center: data-platform
  app.kubernetes.io/instance: not-this-one    # silently dropped — chart's value wins

labels:
  standard:
    enabled: true       # singletons emit standard labels
    version: false      # but suppress app.kubernetes.io/version everywhere
    managedBy: false    # and app.kubernetes.io/managed-by

Effective on a Deployment workload:

  • app.kubernetes.io/name: <chart name> — always (selector invariant)
  • app.kubernetes.io/instance: <release-fullname>-<workload> — always (selector invariant)
  • app.kubernetes.io/part-of: <chart name> — yes (partOf defaults true)
  • app.kubernetes.io/version: … — no (toggle off)
  • app.kubernetes.io/managed-by: … — no (toggle off)
  • cost-center: data-platform — yes
  • app.kubernetes.io/instance: not-this-one — dropped (collides with selector)

Alternatives considered

  • User-wins on label collisions. Rejected: lets users break selectors silently. The whole reason selectors are immutable in Kubernetes is that recovery is expensive.
  • Fail loudly on collision. Rejected: a helm template failure on what is almost always a copy-paste mistake is too brittle. Silent override with documentation is the right trade.
  • Move selectors to a separate, locked-down structure invisible to commonLabels. Rejected: the chart already uses standard app.kubernetes.io/* keys. Inventing parallel keys would break consumer tooling that expects them.
  • Allow toggle-off of selector labels on workloads. Rejected: the API server would reject the resource. A failing render is better than a confusing apply error, but a working render with the labels in is better still.

References

  • values.yaml — narrative for the commonLabels, commonAnnotations and labels.standard.* blocks at the top of the file.
  • templates/_labels.tpl — uhc.labels, uhc.workloadLabels, uhc.workloadSelectorLabels (also uhc.name, uhc.fullname).
  • Tests: tests/integrations_test.yaml, tests/fixtures/commonlabels-conflict.yaml.
  • Related: ADR 006.

012 — Pod-spec hashing for Job idempotency under GitOps

  • Status: Accepted
  • Date: 2025-04-25

Context

Jobs in GitOps look easy until you ask: when should a Job re-run?

  • Argo CD / Flux apply manifests on every sync — sometimes once a minute. A Job named <release>-migrate already exists; the controller sees the manifest unchanged and does nothing. That’s correct if the migration’s intent has not changed.
  • The team bumps the migrator’s image tag from v1 to v2. The manifest changes. But the Job resource’s spec is largely immutable — Kubernetes rejects kubectl apply updates to a Job spec. Argo CD reports the Application as OutOfSync forever.
  • The team works around it with argocd.argoproj.io/hook: PreSync + hook-delete-policy: HookSucceeded. Now the Job is recreated on every sync, even when nothing changed. Migrations re-run. Idempotency is on the migration script, not the chart.

The clean solution is to make the Job’s name a function of its spec. If the spec changes, the name changes — Argo CD sees a new resource and applies it; the old Job is garbage-collected via ttlSecondsAfterFinished. If the spec doesn’t change, neither does the name — sync is a no-op.

Decision

Each jobGroups.<g>.jobs.<j> entry produces a Kubernetes Job (or CronJob) named:

<release-fullname>-<group[:8]>-<job>-<sha8>

where sha8 is the first 8 hex characters of sha256 over a deterministic JSON serialisation of the rendered Job/CronJob spec. Computed by templates/_jobs.tpl:uhc.jobGroupHash.

What’s included in the hash:

  • image.repository, image.tag, image.pullPolicy
  • command, args, tasks (full structure, including each task’s command/args/image)
  • effective env (after the four-layer merge of ADR 003)
  • envFrom (resolved envSecrets + envConfigMaps)
  • volumes, volumeMounts
  • resources
  • securityContext, podSecurityContext
  • nodeSelector, affinity, tolerations, topologySpreadConstraints
  • serviceAccountName
  • restartPolicy, backoffLimit, activeDeadlineSeconds
  • ttlSecondsAfterFinished
  • podAnnotations (effective merge) — included by default, opt out with hashIncludePodAnnotations: false

What’s excluded:

  • metadataAnnotations (chart-managed annotations: sync waves, Reloader, etc. — they shouldn’t trigger re-runs)
  • hooks.argocd.*, hooks.helm.* (changing the hook annotation alone shouldn’t re-run the Job)
  • ttlSecondsAfterFinished change alone doesn’t matter operationally — included for completeness

Opt-out toggles per group:

  • hashSuffix: false — disable hashing entirely; resource name is <release>-<group>-<job> and Job re-creation is the user’s problem.
  • hashIncludePodAnnotations: false — keep hashing on, but exclude podAnnotations from the hash (useful when a mutating webhook adds annotations post-render — Vault, Datadog).

Fail-fast guard. A configuration with hashSuffix: true together with a delete policy that removes the Job after success (hooks.argocd.deletePolicy: HookSucceeded or hooks.helm.deletePolicy: hook-succeeded) is rejected. Reason: every successful run would delete the Job, the next sync would recreate it under the same name (hash unchanged), and the migration would run on every sync — defeating the whole point of hashing.

Consequences

What this enables:

  • A migration runs once per spec change. Argo CD on a 1-minute sync interval is harmless.
  • Image bump (v1 → v2) creates a new Job; the old one’s lifecycle (success / cleanup) is handled via ttlSecondsAfterFinished.
  • Mutating annotations injected by an admission controller (Vault inject) participate in the hash — a new Vault role rotation triggers a re-run, which is usually what the team wants. Opt out via hashIncludePodAnnotations: false when it isn’t.

What it costs:

  • Mutable image tags break idempotency. If image.tag is dev and a new image is pushed under the same tag, the rendered string is unchanged → hash is unchanged → no re-run, even though the binary changed. Workarounds (also documented inline in the jobGroups block of values.yaml):
    1. Argo CD Image Updater with update-strategy: digest — rewrites image.tag to dev@sha256:…. The digest enters the hash.
    2. Immutable tags (semver, git-sha) — every release is its own tag; the hash naturally changes.
    3. Opt out of idempotency on the affected groups (hashSuffix: false + BeforeHookCreation delete policy). Job runs every sync.
  • Old Jobs accumulate in the cluster until ttlSecondsAfterFinished triggers GC. The chart defaults this to 1h; users with churny migrations can tune it.
  • The hash is computed in Go template — see uhc.jobGroupHash for the full algorithm. Stable: any change to the algorithm is a chart breaking change because every group’s Job names would shift, recreating every Job in flight.

Worked example

jobGroups:
  db:
    image:
      repository: my/migrator
      tag: v1
    jobs:
      migrate:
        command: ./migrate.sh

Renders Job myrelease-db-migrate-3f9a1b2c. Bump tag to v2: Job becomes myrelease-db-migrate-7c4d8e1f. Argo CD applies the new Job; ttlSecondsAfterFinished cleans up …-3f9a1b2c an hour after it succeeded.

Alternatives considered

  • Use Helm hooks (pre-install,pre-upgrade) with hook-delete-policy: hook-succeeded. Rejected: re-runs every Helm sync regardless of whether anything changed. Idempotency falls on the migration script.
  • Use Argo CD Sync hook + Replace=true. Rejected: same problem, plus tighter coupling to Argo CD specifically.
  • Hash the entire rendered manifest, not just the spec. Rejected: includes annotations like argocd.argoproj.io/sync-wave whose changes shouldn’t re-run the Job.
  • Per-job idempotencyKey: … user-provided. Rejected: pushes the burden onto the user. The whole point is to make this automatic.

References

  • values.yaml — the jobGroups block carries the narrative on Job idempotency, mutable tags, recommended setups, and the hashSuffix / hashIncludePodAnnotations toggles.
  • templates/_jobs.tpl — uhc.jobGroupHash, uhc.jobGroupSpec (assembles the spec input).
  • templates/job-groups.yaml, templates/cronjob-groups.yaml — fail-fast guard.
  • Tests: tests/job_groups_test.yaml, tests/cronjob_groups_test.yaml.
  • Related: ADR 005

013 — Schema-driven validation, not template-side

  • Status: Accepted
  • Date: 2025-04-25

Context

The chart needs to reject invalid values.yaml early and clearly. There are two common ways to do that in Helm:

  • In templates — sprinkle {{- required "…" .Values.foo }}, {{- if not (regexMatch …) }}{{- fail "…" }}{{- end }}, type checks via kindIs, and so on.
  • In a JSON Schema at values.schema.json, which Helm validates automatically before any template renders.

Template-side validation reads naturally next to the rendering logic, but it has problems:

  • It runs per template, so the same field gets validated multiple times — once per template that touches it. Drift between checks is easy.
  • A single typo at install time triggers a wall of fail messages — one per template that hits the same root cause.
  • Templates are evaluated lazily; some validation paths only fire when a feature is enabled. A user with monitoring.enabled: false could ship a misconfigured monitoring.provider for years and only learn about it the day they flip the switch.
  • Templates have no concept of additionalProperties: false — a typoed key at the top level is silently ignored. Future-you wonders why a setting “doesn’t work”.

JSON Schema sidesteps every one of these.

Decision

All shape validation lives in values.schema.json. Templates render only.

Specifically:

  • Type checks (string vs number vs object) — schema.
  • Enum constraints (provider: prometheus | victoriametrics) — schema.
  • Required fields — schema.
  • Regex / pattern checks (port name length, DNS-1123 label) — schema.
  • additionalProperties: false on every closed object — schema.
  • Conditional validation (if/then/else, oneOf, anyOf) — schema.

Templates may still fail on invariant violations that depend on multiple value paths — e.g. “HPA and KEDA both enabled” (ADR 007) or “hashSuffix: true with deletePolicy: HookSucceeded” (ADR 012). These are cross-field invariants that JSON Schema can sometimes express but with poor error messages; a one-line fail in the template is clearer for the user.

Templates must not call fail for things the schema can express. If you find yourself reaching for a regex check in a template, write it in the schema instead.

Consequences

What this enables:

  • A single typo at the top level (integrationss: instead of integrations:) is caught at helm template / helm install time with a precise error from Helm’s schema validator.
  • Validation is consistent across helm install, helm upgrade, helm template and helm lint.
  • IDEs and editors that understand JSON Schema (yaml-language-server / Red Hat YAML extension; JetBrains family) light up values.yaml with autocomplete and inline errors.
  • Reading the schema is a quick way to see the chart’s value shape — narrower than reading values.yaml’s 1200 lines.

What it costs:

  • The schema is non-trivial (~1100 lines, compared to ~240 lines in values.yaml which carries no inline comments). Maintaining it is a small ongoing tax — the $defs layout keeps the cost roughly linear with the number of distinct shapes, not with the number of call sites.
  • Cross-field invariants still need template-side fails. The chart has a small number of these (HPA/KEDA mutual exclusion, jobGroups hash + delete policy guard) and each is documented in its own ADR.
  • The schema uses JSON Schema draft 2020-12. Older Helm versions may not support every keyword we use; tested with Helm 3.14+.

Schema conventions

  • Closed objects use additionalProperties: false. This catches typos at the leaf. Examples: image, labels.standard, integrations.monitoring.defaults, every chart-defined entry under deployments.<name> / statefulSets.<name> / configMaps.<name> / jobGroups.<name>.jobs.<job> / service.ports.<name> / per-workload keda / hpa / metrics / inherit / headlessService / route entries.
  • Closed shapes go in $defs and are referenced by $ref. Reused shapes (container entry, route entry, service-port entry, hpa/keda/pdb/vpa/networkPolicy/inherit/etc.) live as named definitions. deployments.<name> and statefulSets.<name> are themselves entries in $defs and share most of their structure via the same building blocks. Adding a field to a shape costs one line in one place.
  • K8s-native shapes stay open. resources, affinity, securityContext, podSecurityContext, volumes.<name>, volumeMounts.<container>.<mount>, tolerations, topologySpreadConstraints (the array of constraints), livenessProbe / readinessProbe / startupProbe, lifecycle, nodeSelector, podAnnotations — these mirror upstream Kubernetes types whose fields grow each minor release. Closing them would force-fail on values that pass kubectl apply on a newer cluster, which is worse than letting a typo through. Treat them as pass-through and rely on the API server to reject malformed shapes at admission time.
  • Open maps use additionalProperties: <object schema> with patternProperties on keys. Example: deployments (any DNS-1123 key, value must match the deploymentEntry schema in $defs).
  • Per-feature toggles use if/then to make required fields conditional: e.g. when integrations.eso.externalSecrets.<name>.dataFrom is set, secretStore becomes required.
  • Keep regex sane. Port names: ^[a-z]([-a-z0-9]*[a-z0-9])?$ and length(name) <= 15. Hostnames: standard DNS pattern. Anything more elaborate is suspect. Exception: hosts/hostnames, parentRefs.name, and the templatable ReferenceGrant fields are exempt from a DNS-pattern constraint here. See ADR 020, which evaluates them through tpl; a pre-render pattern would reject legitimate {{ }} values.

Alternatives considered

  • Templates do everything. Rejected: the failure mode is the one we want to leave behind.
  • No validation; trust the user. Rejected: the chart’s values.yaml is large and easy to typo.
  • A JSON Schema generated from values.yaml examples. Rejected: round-tripping examples through a generator is brittle; we hand-write the schema and let humans review it.

References

  • values.schema.json — the schema itself
  • Memory: “don’t put regex/type validation in Helm templates; use schema” — informal team rule that this ADR formalises
  • Helm schema documentation: https://helm.sh/docs/topics/charts/#schema-files
  • Related: ADR 004 (map keys are constrained by schema regex), ADR 007, ADR 012

014 — Deterministic ordering of map-iterated collections

  • Status: Accepted
  • Date: 2025-04-25

Context

The chart’s choice to use maps instead of lists (ADR 004) means every collection iteration has to pick an order. Go maps in templates have an unspecified iteration order; without an explicit sort, the rendered output flips between renders and Argo CD / helm diff show meaningless churn.

For most fields, alphabetical ordering is sufficient. Two fields specifically aren’t:

  • Service / container ports. “http” should come first by convention — that’s what most operators expect when they read a Service’s ports[]. “metrics” should come last — it’s the auxiliary observability port. Alphabetical would put admin before http.
  • Gateway API rule precedence. Gateway API matches rules top-down; the order is part of the contract. We expose a priority field so users can express “this rule before that rule” without coupling to alphabetical key choice.

Decision

Three rules, applied uniformly:

Rule 1: Default — alphabetical sort by map key

Every map-iterating template uses keys ... | sortAlpha before range. This applies to:

  • deployments, statefulSets, jobGroups (workload maps)
  • service.ports (most positions; see Rule 2)
  • volumes, volumeMounts, configMaps, sidecars
  • ingresses, httpRoutes, grpcRoutes, tlsRoutes, referenceGrants
  • integrations.eso.secretStores, integrations.eso.externalSecrets
  • Tasks-mode initContainers inside a Job’s tasks map — note that Kubernetes runs initContainers sequentially in declared order, so the alphabetical sort here is not just a rendering convention but the actual run order. Prefix task names with 01-, 02-, 03- etc. whenever ordering matters.
  • Workload-level deployments.<w>.initContainers and statefulSets.<w>.initContainers — the same constraint applies as for tasks-mode: Kubernetes runs them sequentially in the declared order, so the 01-, 02- prefix convention controls actual run order.

Rule 2: Special-cased port ordering — http first, metrics last

For service.ports and per-container ports, the order is:

"http" (if present)  →  others alphabetically  →  "metrics" (if present)

Implemented in templates/_workload.tpl:uhc.orderedPortNames. The metrics port auto-injected by integrations.monitoring.defaults.exposeService (ADR 016) is also appended last.

Rationale: the first port in a Service’s ports[] is the one that matters most — health probes, kubectl port-forward shorthand, default network-policy assumptions all point at it. Putting “http” first makes the common case consistent. Putting “metrics” last keeps observability ports out of the way of operational reading.

Rule 3: Explicit priority overrides alphabetical for ordered semantics

For per-workload Gateway API route shorthand (deployments.<w>.httpRoute.priority, similarly grpc/tls), users can set an integer. Lower numbers come first in the merged route’s rules[]. Ties broken alphabetically by workload name. Workloads without priority are sorted alphabetically among themselves (and the chart treats them as priority +∞ when comparing with explicitly-prioritised entries).

Consequences

What this enables:

  • A helm template rerun on identical inputs produces byte-identical output. Argo CD / helm diff only show meaningful changes.
  • Operators reading Service definitions can expect “http” first, “metrics” last — a predictable mental model across services.
  • Gateway API rule precedence is explicit and stable across renders.

What it costs:

  • Renaming a workload changes its alphabetical position. In Gateway API rule order this matters — deployments.api vs deployments.zapi change the order in the merged HTTPRoute. Mitigation: use priority to lock down rule order independently of key naming.
  • Users wanting a non-standard port order (e.g. metrics first) cannot get it without renaming their port. The chart treats this as a feature.
  • The keys ... | sortAlpha pattern is repeated across templates. Refactoring to a single helper is on the wishlist but the cost of inconsistency in the meantime is low.

Worked examples

deployments:
  api:
    service:
      ports:
        admin:
          port: 8081
          targetPort: 8081
        http:
          port: 80
          targetPort: 8080
        metrics:
          port: 9090
          targetPort: 9090
        rpc:
          port: 9000
          targetPort: 9000

Rendered Service ports[] order:

http, admin, rpc, metrics

Gateway API rule merge — three workloads, two with explicit priority, one without:

deployments:
  z-api:
    httpRoute:
      enabled: true
      priority: 10
      rules: [...]
  api:
    httpRoute:
      enabled: true
      priority: 20
      rules: [...]
  web:
    httpRoute:
      enabled: true
      rules: [...]

Order in merged HTTPRoute rules[]: z-api (priority 10), api (priority 20), web (no priority, alphabetised among unprioritised — only one here, but if home also had no priority it would precede web).

Alternatives considered

  • Pure alphabetical for ports too. Rejected: violates the operational intuition that http is the “main” port.
  • Insertion order from values.yaml. Rejected: not portable across YAML libraries; merging overlay values would break any guarantee.
  • Explicit order integer on every map entry. Rejected: more knobs everywhere, and most users don’t care.
  • Per-port weight field instead of fixed http/metrics rule. Rejected: solves a problem that doesn’t exist for the 99% case.

References

  • templates/_workload.tpl — uhc.orderedPortNames
  • templates/service.yaml, templates/deployment.yaml, templates/statefulset.yaml (ports rendering)
  • templates/httproute.yaml, templates/grpcroute.yaml, templates/tlsroute.yaml (rule merge with priority)
  • Tests: tests/service_test.yaml, tests/fixtures/service-multiport.yaml, tests/fixtures/httproute-sort.yaml
  • Related: ADR 002, ADR 004, ADR 009, ADR 016

015 — ESO data vs dataFrom and chart-vs-external disambiguation

  • Status: Accepted
  • Date: 2025-04-25

Context

External Secrets Operator (ESO) gives two ways to extract values from a backend (Vault, AWS, GCP, etc.) into a Kubernetes Secret:

  • data[] — explicit per-field mapping. Each entry says: “fetch this remoteKey (and optionally a specific property within it) and put it in the resulting Secret under this secretKey.” property selects a sub-field for backends that store structured values (Vault KV-v2, JSON-shaped AWS SecretsManager); for plain-string secrets (AWS SecretString, simple GCP values) it is omitted and ESO returns the whole stored value. Granular, predictable, type-checked.
  • dataFrom[] — bulk extraction. “Fetch the whole remoteKey and merge all its fields into the resulting Secret.” Saves typing when the remote secret is already shaped right; obscures which fields end up in K8s.

Both have legitimate uses. The chart needs to support both without making the values shape ambiguous.

A second disambiguation problem: the chart manages its own configMaps (under .Values.configMaps) and reads external envConfigMaps (a list of names). When a user lists app-config under envConfigMaps and also defines a configMaps.app-config block with its own data, which app-config is mounted? The chart-managed one (named <release-fullname>-app-config) or some external one called literally app-config?

The same problem exists for ExternalSecret targets: a targetName collision with a chart-owned ConfigMap or a user-supplied Secret name.

Decision

data vs dataFrom

In values.yaml:

  • data is a map keyed by the resulting Secret’s field name (secretKey). Map key → spec.data[].secretKey in the rendered ExternalSecret. This makes data deterministic and overlay-mergeable per ADR 004:
    externalSecrets:
      db-creds:
        data:
          DB_PASSWORD:                    # ← becomes data[].secretKey
            remoteKey: my-app/db
            property: password
    
  • dataFrom is a list. A natural unique key doesn’t exist (the same remoteKey can appear twice with different conversionStrategy / decodingStrategy overrides), and order can matter for merge semantics in some backends.
  • Both can coexist in one ExternalSecret. ESO merges them; explicit data entries win on conflicting keys.

Chart-owned vs external naming collisions

The chart computes the rendered name in this order:

  1. Chart-owned name first. If the values reference a name that also appears as a key in .Values.configMaps (or .Values.integrations.eso.externalSecrets), the rendered reference points at the chart-managed resource (<release-fullname>-<name>).
  2. External otherwise. The reference is rendered as the literal name.

Implementation lives in templates/_workload.tpl:uhc.containerSpecWithOptions (the envFrom resolution) and in the schema’s additionalProperties rules.

The chart documents this explicitly in the envConfigMaps block of values.yaml:

On collision (an external CM and a chart-owned configMaps.<name> with the same name) the chart-owned one wins and the external CM is shadowed.

Consequences

What this enables:

  • Granular data declarations get all the benefits of map shape: overlay merging, duplicate prevention, per-key disabling.
  • Bulk dataFrom stays expressible without forcing artificial keys.
  • The “two ConfigMaps with the same name” failure mode becomes impossible: the chart picks one and silently shadows the other. Users see exactly one app-config in the rendered Pod.

What it costs:

  • “Chart-owned wins” is silent. A user who intended to reference an external app-config and happens to have a configMaps.app-config block in their values will get the chart-owned one. The chart’s defence is that having both is itself a code smell — pick distinct names if you need both. Documented in the envConfigMaps block of values.yaml.
  • dataFrom doesn’t dedupe on collision with explicit data in the chart layer; ESO does the dedupe on the backend at apply time. We accept this inconsistency because dedupe in the chart would require knowing each backend’s per-field mapping, which we don’t.

Worked example

configMaps:
  app-config:
    enabled: true
    data:
      LOG_LEVEL: info

envConfigMaps:
  - app-config       # → resolves to <release>-app-config (chart-owned wins)
  - other-team-cm    # → resolves to other-team-cm (external)

integrations:
  eso:
    externalSecrets:
      db-creds:
        secretStore: vault-store
        targetName: db-creds            # → K8s Secret named db-creds
        data:
          DB_PASSWORD:
            remoteKey: my-app/db
            property: password
        dataFrom:
          - remoteKey: my-app/redis
            conversionStrategy: Default

Result: one ConfigMap (<release>-app-config) shadowing nothing because no collision exists; envConfigMaps references the chart-owned one. One ExternalSecret with two extraction methods feeding one K8s Secret.

Alternatives considered

  • data as a list. Rejected: loses overlay-merge wins, loses duplicate prevention.
  • dataFrom as a map. Rejected: requires inventing synthetic keys (bulk_1, bulk_2); duplicates are legitimate.
  • External-wins on name collisions. Rejected: the chart-owned ConfigMap presumably has the right value (it’s in the chart’s values); the external reference is presumed to be the older, possibly stale source.
  • Fail loudly on collision. Rejected: the warning belongs in the schema; a render-time fail is too noisy for a configuration smell.

References

  • values.yaml — the integrations.eso.externalSecrets block (narrative on data vs dataFrom) and the envConfigMaps block (chart-owned vs external resolution).
  • templates/externalsecret.yaml — renders both data and dataFrom.
  • templates/_workload.tpl — uhc.containerSpecWithOptions (envFrom resolution).
  • Tests: tests/externalsecret_test.yaml; fixtures es-data.yaml, es-mixed.yaml, es-datafrom-auto.yaml, es-datafrom-overrides.yaml.
  • Related: ADR 004, ADR 006.

016 — Metrics port auto-exposure on Service

  • Status: Accepted
  • Date: 2025-04-25

Context

Most apps don’t expose /metrics on the same port as their main API:

  • Security. The metrics endpoint shouldn’t be reachable from Ingress. Splitting the port keeps it cluster-internal by default.
  • Allowed-hosts middleware. Django, Spring, FastAPI all have host-validation middleware. Mounting the metrics path on the API port forces relaxing the middleware for in-cluster scrape probe IPs — a common source of audit findings.
  • Scrape isolation. A 503-storm from a misbehaving app shouldn’t impact metrics scraping; running them on different processes / sockets gives that for free.

The mechanical consequence: the chart needs a “metrics” port on the Service, with a matching containerPort in the pod template. Users who manage this manually end up writing the same port-pair declaration on every workload. The boilerplate is small but multiplied by the number of workloads.

A second concern: some workloads have no main Service at all (a pure batch worker, a gRPC client). They still want to expose /metrics. A monitoring-only Service is the right shape.

Decision

A single switch at integrations.monitoring.defaults.exposeService.enabled: true (overridable per workload via metrics.exposeService.enabled) auto-emits the metrics port. The chart’s behaviour is:

  • Workload has a main Service (service.enabled: true and at least one entry in service.ports): append a metrics port to the Service’s ports[] and a matching containerPort to the workload’s main container. Sidecars, init containers and job containers never get it — ContainerPort.name has to be unique within a Pod, and they do not serve the endpoint. Port name is fixed as metrics (Kubernetes convention).
  • Workload has no main Service: emit a dedicated Service with only the metrics port. Name: <release-fullname>-<workload>-metrics. On a StatefulSet the governing headless Service takes the port instead whenever the chart renders it; the dedicated Service appears only when neither Service exists. This Service is also picked up by the ServiceMonitor / VMServiceScrape (when CRD discovery + service target is selected — see ADR 008).
  • metrics.type: pod: this whole feature is skipped. PodMonitor / VMPodScrape don’t need a Service — the scrape happens directly against pod IPs.
  • service.ports.metrics already declared: the auto-injection is skipped to avoid a duplicate port. The user’s explicit declaration wins.

The metrics port is always rendered last in any ports[] list — see ADR 014 for the http-first / metrics-last rule.

Defaults at integrations.monitoring.defaults.exposeService:

enabled: false        # opt-in
port: 9090            # ServicePort
targetPort: 9090      # containerPort

Consequences

What this enables:

  • One flag (integrations.monitoring.defaults.exposeService.enabled: true plus metrics.enabled: true per workload) gets a fully-wired metrics port on Service, pod template, and ServiceMonitor / VMServiceScrape.
  • Pure batch workers can be scraped via a metrics-only Service without a main Service.
  • The “metrics is not on http” pattern is easy to do right and hard to do wrong.

What it costs:

  • When a user declares service.ports.metrics explicitly, they may not realise they’ve disabled the auto-exposure feature (the chart doesn’t emit two ports). The skipping rule is documented next to the integrations.monitoring.defaults.exposeService block in values.yaml.
  • The dedicated metrics-only Service for service-less workloads adds an object that an inattentive operator might miss. We accept this — it matches the intuition that “every scrape target has a Service.”
  • The fixed port name metrics is a chart convention. Users with idiosyncratic monitor configs that target other names must declare the port manually in service.ports instead.

Worked example

integrations:
  monitoring:
    defaults:
      enabled: true
      exposeService:
        enabled: true
        port: 9090
        targetPort: 9090

deployments:
  api:
    service:
      ports:
        http:
          port: 80
          targetPort: 8080
    # No explicit metrics port; auto-injection adds one
    metrics:
      enabled: true
  batch:
    service:
      enabled: false       # no main Service — but metrics scraping still wanted
    metrics:
      enabled: true
      type: service        # explicit: a metrics-only Service is rendered

Result:

  • <release>-api Service with two ports: http (80) and metrics (9090).
  • <release>-batch-metrics Service with one port: metrics (9090).
  • ServiceMonitor / VMServiceScrape per workload, targeting the metrics port name.

Alternatives considered

  • Always require explicit declaration. Rejected: the boilerplate costs scale with workload count and the right shape is the same every time.
  • Auto-expose on the main port if no metrics port declared. Rejected: hides the security / middleware caveats described in Context.
  • Configurable port name. Rejected: metrics is the de-facto Kubernetes convention; supporting other names invites ServiceMonitor configuration drift.

References

  • values.yaml — the integrations.monitoring.defaults.exposeService block.
  • templates/service.yaml — port injection (the metrics port lands last).
  • templates/_metrics.tpl — uhc.metricsExposeService.
  • Tests: tests/service_test.yaml, tests/fixtures/service-existing-metrics-port.yaml.
  • Related: ADR 006, ADR 008, ADR 014.

017 — Reloader annotation injection

  • Status: Accepted
  • Date: 2025-04-25

Context

Stakater Reloader is a small Kubernetes controller that watches ConfigMaps and Secrets, and triggers a rolling restart of any Deployment / StatefulSet that mounts a changed one. Activation is by annotation on the workload:

  • reloader.stakater.com/auto: "true" — auto-discover all referenced ConfigMaps / Secrets, restart on change.
  • reloader.stakater.com/match: "true" plus reloader.stakater.com/search-cm-only: "true" (or *-secret-only) — narrow the discovery.

The chart already manages every ConfigMap and Secret a workload references. Asking users to also annotate the workload by hand is mechanical busywork that’s easy to forget when adding the second ConfigMap.

A second concern is ExternalSecret targets: ESO-managed Secrets are written by the controller, not by Helm. Annotating the ExternalSecret directly does nothing — Reloader watches the resulting K8s Secret. ESO does propagate spec.target.template.metadata.annotations onto the generated Secret, so the right place to land the Reloader annotation is on the ExternalSecret’s target template.

Decision

A single switch at integrations.stakater.reloader.enabled: true injects the Reloader annotation onto the right resources.

Where the annotation lands:

  • Deployment.metadata.annotations
  • StatefulSet.metadata.annotations
  • ConfigMap.metadata.annotations (chart-owned ConfigMaps from .Values.configMaps)
  • ExternalSecret.spec.target.template.metadata.annotations — ESO copies these onto the generated Secret, so Reloader sees the change.

What annotation lands:

  • When integrations.stakater.reloader.annotations is empty (the default), the chart applies the single annotation reloader.stakater.com/auto with value "true".
  • When integrations.stakater.reloader.annotations is non-empty, that map fully replaces the default. There is no merge. This is intentional — Reloader’s annotation set is mutually exclusive (auto vs match-based discovery; cm-only vs secret-only) and a partial merge would produce confusing combinations.

Consequences

What this enables:

  • One toggle wires Reloader into every workload + ConfigMap + ESO-managed Secret in the release.
  • Switching from auto-discovery to match-strategy is a one-block change in values.yaml:
    integrations:
      stakater:
        reloader:
          enabled: true
          annotations:
            reloader.stakater.com/match: "true"
            reloader.stakater.com/search-cm-only: "true"
    
  • Workloads consuming ESO-managed Secrets get rolling restarts on remote-secret rotation without any extra configuration.

What it costs:

  • The “no merge with user annotations” rule is unusual elsewhere in the chart (most other annotation sets do merge with user-supplied entries). Documented next to the integrations.stakater.reloader block in values.yaml. The choice keeps Reloader’s strategy unambiguous.
  • The annotation is not added to Job / CronJob templates — Reloader doesn’t restart Jobs (they’re one-shot), and re-creating a finished Job to “pick up” a Secret change isn’t useful. Job recreation is governed by the spec hash (ADR 012) instead.
  • Reloader must be installed in the cluster. The chart doesn’t check; if the controller isn’t there, the annotation is inert.

Default behaviour

Off by default (enabled: false). Reloader is a useful but optional integration; releases that don’t need restart-on-config-change pay nothing for it.

Alternatives considered

  • Merge user-supplied annotations with the auto: "true" default. Rejected: Reloader’s discovery strategies are mutually exclusive; a merge produces undefined behaviour.
  • Annotate only Deployment / StatefulSet, leave ConfigMap and ExternalSecret untouched. Rejected: ConfigMap annotation is a Reloader convenience for resources it might otherwise overlook (e.g. CMs only mounted as volumes); ESO target template annotation is required for ESO-managed Secrets. Skipping either leaves a hole.
  • Per-workload Reloader toggle. Rejected: in practice teams either want Reloader-driven restarts everywhere in a release or nowhere. A per-workload knob doubles the surface for a vanishingly small fraction of use cases.

References

  • values.yaml — the integrations.stakater.reloader block.
  • templates/_annotations.tpl — uhc.reloaderAnnotations.
  • templates/deployment.yaml, templates/statefulset.yaml, templates/configmap.yaml, templates/externalsecret.yaml — annotation injection sites.
  • Stakater Reloader: https://github.com/stakater/Reloader.
  • Related: ADR 006, ADR 012.

018 — Testing with helm-unittest and fixtures

  • Status: Accepted
  • Date: 2025-04-25

Context

The chart’s blast radius is wide: a one-character helper edit lands on every consumer at next upgrade. We need a regression suite that:

  • runs in CI on every PR and is fast enough to run before commit too;
  • catches subtle template bugs (a missing field, a wrong sort key, a label drift);
  • is readable enough that a contributor adding a feature can write the test without reverse-engineering a framework;
  • doesn’t require a real Kubernetes cluster.

The Helm ecosystem has two main options:

  • helm-unittest — a Helm plugin that runs YAML-defined test suites against the rendered chart. Tests live next to the chart in tests/. Asserts on rendered manifests via equal, matchRegex, isKind, notExists, etc. Supports snapshot tests.
  • chart-testing (ct) + a real cluster — installs the chart into kind / minikube and asserts via kubectl. Heavier; useful for integration tests but overkill for shape regressions.

Decision

Use helm-unittest as the chart’s primary test framework. Tests live in tests/, fixtures in tests/fixtures/, snapshots (when used) in tests/__snapshot__/.

Conventions:

  • One test suite per template. tests/deployment_test.yaml covers templates/deployment.yaml; tests/job_groups_test.yaml covers templates/job-groups.yaml. Cross-cutting features (e.g. metrics annotations, integrations) get their own files (tests/metrics_annotations_test.yaml, tests/integrations_test.yaml).
  • Prefer assertion tests over snapshots. Assertions are descriptive (isKind: Deployment, equal: spec.replicas: 3); they survive harmless renames in unrelated parts of the manifest. Snapshots are byte-for-byte and break loudly on any whitespace change. We use snapshots only where the rendered output is a complex multi-document YAML and asserting field-by-field would be tedious — currently no snapshot fixtures exist (tests/__snapshot__/ is empty).
  • Fixtures for non-trivial inputs. A test asserting that service.ports renders in http-first / metrics-last order (see ADR 014) reads a set: block from tests/fixtures/service-multiport.yaml rather than inlining 30 lines of YAML. Each fixture has a focused purpose; the file name describes it (es-datafrom-auto.yaml, commonlabels-conflict.yaml).
  • Test against the schema too. helm-unittest runs Helm’s schema validation as part of rendering. A test case that violates values.schema.json fails the suite — this catches schema regressions.
  • Negative tests are valuable. “This combination should fail” asserts via failedTemplate confirm fail-fast guards (HPA + KEDA, jobGroups hash + delete policy) actually fire.

The CI workflow at .github/workflows/ci.yaml runs helm lint then helm unittest .. The release workflow at .github/workflows/release.yaml runs the same checks before pushing the OCI artifact.

Consequences

What this enables:

  • A new feature lands with tests. PR reviewers can run helm unittest . locally in seconds.
  • The fixture catalogue serves as executable documentation: reading tests/fixtures/keda-deployment.yaml shows a working KEDA + Deployment combination quicker than reading the docs.
  • Regressions are caught before merge. The helper library (templates/_labels.tpl, _annotations.tpl, _env.tpl, _workload.tpl, _metrics.tpl, _jobs.tpl, _helpers.tpl) is dense; tests are the only practical safety net.

What it costs:

  • helm-unittest is a plugin, not part of upstream Helm. Contributors install it once (helm plugin install https://github.com/helm-unittest/helm-unittest --verify=false); CI does the same.
  • Test files are large (tests/deployment_test.yaml is ~34 KB). Discoverability suffers when one suite covers many cases; we mitigate by ordering test cases by feature (replicas, env, volumes, sidecars, …) and using descriptive it: titles.
  • Snapshot tests, when added, will need a clear “regenerate snapshot” runbook. Until we have one, prefer assertion tests.

What makes a good fixture

A good fixture in tests/fixtures/:

  • Has a single, narrow purpose. es-datafrom-auto.yaml shows ESO dataFrom auto-derivation, nothing else.
  • Names the purpose in the file name. A reviewer reading a test that loads commonlabels-conflict.yaml immediately knows what’s being tested.
  • Stays minimal. The fewer values it sets, the less work a future change to the chart does to keep it valid.
  • Is loaded via set: blocks that point at the fixture; the test asserts on the rendered output, not the fixture itself.

Alternatives considered

  • chart-testing against kind. Rejected as the primary suite: too slow, too much infrastructure for shape-regression coverage. May add later as a thin smoke-test layer.
  • Pytest + helm template in a subprocess. Rejected: re-implements what helm-unittest already does, with worse readability for chart authors.
  • Snapshots for everything. Rejected: a label rename touches every snapshot and produces noisy PRs that hide signal.
  • No tests; rely on review. Rejected; obviously.

References

  • tests/ — all test suites
  • tests/fixtures/ — focused inputs
  • tests/__snapshot__/ — snapshot directory (currently empty)
  • .github/workflows/ci.yaml, .github/workflows/release.yaml — CI pipelines
  • helm-unittest: https://github.com/helm-unittest/helm-unittest
  • Related: ADR 013 (schema validation runs as part of unittest)

019 — Pre-fill API-server defaults on atomic list fields

  • Status: Accepted
  • Date: 2026-08-19

Context

Argo CD synced with syncOptions: [ServerSideApply=true] reported a handful of this chart’s resources as permanently OutOfSync, even immediately after a clean sync with no pending git change: StatefulSet.spec.volumeClaimTemplates, and every Gateway API route kind’s spec.parentRefs / spec.rules[].backendRefs (HTTPRoute, GRPCRoute, TLSRoute).

Reproduced directly against a kind cluster: the Kubernetes API server (or, for Gateway API, the CRD’s own OpenAPI schema defaults) fills in fields on these objects that the chart never rendered — apiVersion: v1, kind: PersistentVolumeClaim, spec.volumeMode: Filesystem, and status: {phase: Pending} on each volumeClaimTemplates entry; group/kind on parentRefs and backendRefs entries; weight: 1 on backendRefs; matches[].method.type: Exact on GRPCRoute matches left unset.

None of this is a bug in the objects themselves — Kubernetes is allowed to default fields the client didn’t set. The problem is specific to how these fields are shaped: each one lives inside a list field with no merge key (x-kubernetes-list-type: atomic, or no strategic-merge-patch directive at all for core volumeClaimTemplates). Struct-shaped fields we partially specify (e.g. ExternalSecret.spec.target, which gets deletionPolicy and template.engineVersion defaulted) merge key-by-key under both classic apply and Server-Side Apply, so an unmentioned sibling field is simply left alone. List entries without a merge key don’t get that courtesy: once a field manager owns the list, it owns it as a whole unit, and any element field present live but absent from the desired manifest reads as a pending patch back to the (shorter) desired version — forever OutOfSync, because the server immediately re-adds the same defaults on the next reconcile.

This only surfaces under ServerSideApply=true. Argo CD’s default (client-side) apply path doesn’t flag it — its diff has more lenient handling of fields it never took ownership of. ServerSideApply=true is a common Argo CD recommendation, though, so charts that skip this only work by accident for users on the default sync strategy.

Decision

Where the chart renders an atomic-list field whose element shape includes fields we know the server (or CRD schema) will default, pre-fill those exact defaults in the template, using a plain-key-wins merge (merge (deepCopy $userValue) $defaults) so any value the user did set is untouched:

  • templates/statefulset.yaml — each volumeClaimTemplates entry gets apiVersion: v1, kind: PersistentVolumeClaim, spec.volumeMode: Filesystem (default), status: {phase: Pending}.
  • templates/_gateway.tpl — uhc.gatewayParentRefs fills group: gateway.networking.k8s.io, kind: Gateway on every parentRefs entry; uhc.gatewayBackendRefs fills group: "", kind: Service, weight: 1 on every backendRefs entry (both helpers, used by HTTPRoute, GRPCRoute, TLSRoute, root and per-workload and plural-map render sites alike). uhc.grpcRouteRuleSpec additionally fills matches[].method.type: Exact when a method match is set without one.

Desired now equals live byte-for-byte on these fields, so the diff Argo CD would otherwise (re-)compute under Server-Side Apply is empty regardless of sync strategy.

Consequences

What this enables:

  • No ignoreDifferences required for volumeClaimTemplates / Gateway API routes, on any Argo CD sync strategy (client-side or ServerSideApply=true).
  • The fix is invisible to users who never look at the rendered YAML — user-supplied field values always win via the merge, only genuinely-omitted fields get filled.

What it costs:

  • The rendered manifests carry a few more explicit fields (apiVersion, kind, status: {phase: Pending} inside volumeClaimTemplates; group/kind/weight on route refs) that most hand-written manifests leave implicit. status: inside a spec-embedded PersistentVolumeClaim template reads unusually at first glance — it’s real, structurally valid content (StatefulSetSpec.VolumeClaimTemplates is typed []PersistentVolumeClaim, status included), not a mistake.
  • If a future Kubernetes/Gateway API release changes what gets defaulted (e.g. a new PersistentVolumeClaim default field, or Gateway API adding a field to parentRefs), this list of pre-filled defaults needs a matching update, or the diff reappears for that one new field. Confirmed only for the fields listed above — other Gateway API match-level defaults (e.g. HTTPRoute matches[].path.type) follow the identical pattern but weren’t hit by the values used to reproduce this, so aren’t pre-filled yet.
  • Only fixes fields shaped as unmerged lists. A struct field the server defaults (like ExternalSecret.spec.target.deletionPolicy) was already fine and needed no change; a chart bug that under-specifies a new unmerged-list field in the future will need the same treatment, not this one applied blindly everywhere.

Alternatives considered

  • Document ignoreDifferences for affected users instead of changing chart output. Rejected as the primary fix: it pushes a per-consumer Argo CD config burden onto everyone who happens to use ServerSideApply=true, for a gap the chart can close for free. Still valid as a fallback for any future unmerged-list default this ADR’s list doesn’t yet cover.
  • Switch affected CRD list fields to a merge-key strategy chart-side. Not possible — x-kubernetes-list-type/patch strategy is defined by the upstream API (core Kubernetes types, Gateway API CRDs), not something a chart consuming those types can override.
  • Recommend users avoid ServerSideApply=true. Rejected: it’s a reasonable, commonly-recommended Argo CD setting for unrelated reasons (large manifests, multi-controller field ownership); the chart shouldn’t have an opinion on it.

References

  • templates/statefulset.yaml, templates/_gateway.tpl — the fix.
  • templates/externalsecret.yaml — an example of a struct field (spec.target) that needed no change, for contrast.
  • Related: ADR 010 (other Argo CD-specific chart behavior).

020 — tpl-evaluated hostnames and parentRefs

  • Status: Accepted
  • Date: 2026-08-19

Context

values.yaml authors need hostnames and Gateway API refs that vary by release, most commonly a per-namespace or per-environment hostname such as {{ .Release.Namespace }}.example.com. Until now, templates/ingress.yaml, templates/httproute.yaml, templates/grpcroute.yaml, templates/tlsroute.yaml, and templates/referencegrant.yaml emitted these values straight from .Values, with no tpl pass. A Go-template expression written there reached the rendered manifest as a literal string, and the Kubernetes API server rejected it (invalid DNS label, invalid resource name). Users had no way to parameterize these fields short of maintaining a separate values file per environment.

Decision

Evaluate the following fields through Helm’s tpl function against the root release context, at every render site (singleton and plural-map forms alike):

  • Ingress hosts (map keys) and tls[].hosts[].
  • HTTPRoute / GRPCRoute / TLSRoute hostnames[].
  • parentRefs[].name / .namespace / .sectionName, shared by all three route kinds through uhc.gatewayParentRefs.
  • ReferenceGrant from[].namespace and to[].name, through two new helpers, uhc.referenceGrantFrom / uhc.referenceGrantTo.

tpl on a plain string with no {{ }} is a no-op, so every site templates unconditionally. There’s no if strings.Contains "{{" gate.

Explicitly out of scope, left untouched: backend/service-name fields (Ingress defaultBackend.serviceName, path-level serviceName, backendRefs[].name via uhc.gatewayBackendRefs), and the *RouteRuleSpec helpers in general. Per-workload deployments / statefulSets shorthand httpRoute / grpcRoute / tlsRoute blocks carry no hostnames/parentRefs of their own. Those only live on the singleton or plural-map entries (see ADR 009), so no per-workload merge-section changes were needed either.

Two fields get a post-tpl empty-string fail. Kubernetes structurally requires a non-empty value there, and today’s presence-only fail guards (checking the raw list/map isn’t empty, never individual entry content) would let a template that evaluates to "" sail through unnoticed: parentRefs[].name (via uhc.gatewayParentRefs) and TLSRoute hostnames[] entries (TLSRoute matches purely by SNI, so an empty entry is unambiguously broken). Every other touched field gets no such guard: Ingress host/tls[].hosts[], HTTPRoute/GRPCRoute hostnames[], and ReferenceGrant from[].namespace/to[].name are all optional overall, an empty Ingress host is legitimately valid Kubernetes (“match all hosts”), and the API server already rejects a genuinely broken value clearly enough. Adding a chart-side guard everywhere would be scope creep beyond fixing the literal-{{ }} bug, per ADR 013’s “templates render, don’t over-validate” default for anything the schema or API server can already catch.

Ingress host is a YAML map key, not a plain scalar. Only the emitted value is templated. keys .Values.ingress.hosts | sortAlpha and the index lookup that pairs a key with its path config both keep using the raw, untemplated key string. Templating the key itself would mean rendering every host up front to re-key the map, a bigger change not needed to stop a literal {{ }} from reaching the API server. One consequence: sort order across multiple templated hosts follows the literal source text, not the rendered hostname. That’s cosmetic, not functional, and worth stating here so it doesn’t read as an oversight.

Resolving the tension with ADR 013

ADR 013’s schema-conventions section states an intent, not yet implemented, that hostname fields should eventually get “a standard DNS pattern” in values.schema.json. A literal pre-render regex would reject every legitimate {{ }} value this ADR now supports. This ADR formally amends that line: hosts, hostnames, parentRefs.name, and the templatable ReferenceGrant fields are exempt from any future values.schema.json DNS-pattern constraint. If DNS-shape validation on these fields is wanted later, it must run post-render: a template-side fail using regexMatch on the already-tpl’d value. That’s a deliberate, documented exception to ADR 013’s general “no regex in templates” rule, justified because the schema literally cannot see a value that only exists after render. It’s never a values.schema.json pattern.

Consequences

What this enables:

  • Per-namespace/per-environment hostnames and refs from one shared values.yaml, no more literal-{{ }}-in-manifest rejections, no separate values file per environment just to vary a hostname.

What it costs:

  • tpl is now a pattern in this chart; it wasn’t used anywhere before this change. The scope here is deliberately narrow: hosts/hostnames and parentRefs/ReferenceGrant identity fields only, not “every string field.” Any future extension should get its own ADR rather than assuming this one’s scope silently grows.
  • A tpl’d value has access to the full render context (.Values, .Release, .Chart, .Capabilities, .Files, .Template). That’s standard tpl behavior, not a new trust boundary beyond what chart authors already extend to anyone who can write values.yaml.
  • The empty-guard asymmetry above (two fields guarded, the rest not) is intentional, not an inconsistency. It’s documented here so a future reader doesn’t try to “fix” it into either full coverage or none.

Alternatives considered

  • Do nothing; tell users to --set fully-resolved hostnames per environment, or maintain one values file per environment. Rejected: defeats the point of a shared, reusable values.yaml.
  • Gate each tpl call behind if strings.Contains "{{". Rejected: tpl on a plain string is already a safe no-op, so the gate adds complexity for no behavior change.
  • Validate hostname shape in values.schema.json pre-render. Rejected: this is the exact tension the “Resolving the tension” section above resolves. A pre-render pattern breaks templated values.

References

  • templates/_gateway.tpl: uhc.gatewayParentRefs, uhc.referenceGrantFrom, uhc.referenceGrantTo.
  • templates/ingress.yaml, templates/httproute.yaml, templates/grpcroute.yaml, templates/tlsroute.yaml, templates/referencegrant.yaml: the render sites.
  • ADR 013: schema-driven validation, amended by the “Resolving the tension” section above.
  • ADR 009: confirms hostnames/parentRefs live only on singleton/plural-map entries, never per-workload shorthand.
  • ADR 019: the parentRefs group/kind pre-fill logic this change sits next to in uhc.gatewayParentRefs.

021 — Optional single-workload naming

  • Status: Accepted
  • Date: 2026-09-07

Context

The chart names every per-workload resource <fullname>-<workloadName> and puts the workload key in app.kubernetes.io/name, with the full construction in app.kubernetes.io/instance. That suffix is what lets several workloads share one release without colliding (see ADR 002).

Plenty of releases run a single workload. There the suffix buys nothing and costs something: myrel-payments-api where the service is already called payments, DNS names longer than they need to be, and a mismatch with the per-service charts teams migrate away from, whose Service was simply <release>. Callers that hardcode a Service DNS name have to be edited as part of the migration, which is exactly the friction the universal chart is supposed to remove.

Decision

naming.omitWorkloadSuffix (default false) builds every per-workload resource name from <fullname> alone. All names route through one helper, uhc.workloadResourceName, so the flag reaches Deployment, StatefulSet, Service, HPA, ScaledObject, VPA, PDB, NetworkPolicy, ServiceMonitor, PodMonitor and the workload ConfigMap, including the -headless, -metrics and -config variants. Labels and selectors follow through uhc.workloadLabels / uhc.workloadSelectorLabels: app.kubernetes.io/name becomes the chart name and app.kubernetes.io/instance becomes <fullname>. Singleton resources are a separate pair and do not move: uhc.labels keeps emitting app.kubernetes.io/instance: <release name> there.

With more than one enabled entry across deployments and statefulSets, helm template fails and names the offenders. Silently keeping the suffix would make the flag mean different things in different releases, and honoring it would render several workloads under one set of names.

Container names keep the workload key. jobGroups and the ESO entries keep both their names and their labels, and say so at the call site with keepSuffix: a release holds at most one workload under this flag but any number of job groups and secrets beside it, so those are addressed by their own key rather than by release identity. Without that, every Job pod would carry the workload’s selector labels and land in its Service endpoints, NetworkPolicy and PDB.

Consequences

  • Off by default, and the rendered output with it off is byte-identical to before, so no existing release moves.
  • Flipping it on an existing release renames the workload and changes an immutable selector, so the Deployment or StatefulSet is replaced rather than updated. It is an install-time decision.
  • With the suffix gone, a workload resource and the release ServiceAccount share a name. Different kinds, so Kubernetes accepts it.
  • Adding a second workload to a release that uses the flag fails the render until the flag is turned off. The error says so.

Alternatives considered

  • Fall back to suffixed names when a release grows past one workload. The same values would produce different names depending on how many workloads are defined, and adding a workload would silently rename the first one.
  • Derive it automatically from a single-workload release. Same problem without even an opt-in to point at.
  • Leave labels and selectors suffixed and rename objects only. Cheaper to toggle, but it leaves app.kubernetes.io/name: api on a release that no longer names anything api.

References

  • naming.omitWorkloadSuffix in values.yaml.
  • Helpers uhc.omitWorkloadSuffix, uhc.workloadResourceName in templates/_labels.tpl.
  • ADR 002, ADR 011.