Helm chart type-level defaults with per-entry overrides
Status: accepted
Date: 2026-07-29
Relates to: ADR-0049 (caliper emission worker host — a services entry), ADR-0051 (two docs
sites — two apps entries), infra/charts/emergent/.
Context
Section titled “Context”The emergent chart renders every frontend from one set of apps templates and every backend from
one set of services templates, iterating .Values.apps / .Values.services. The templates read
every key directly off the entry, so every entry had to carry every key: five app blocks of ~170
lines and three service blocks of ~80 lines, of which roughly eight scalars per entry were genuine
differences (hostname, cert name, data-image repository, ports, entrypoint, probe paths). The same
boilerplate was then restated per entry in the environment override files.
The duplication had already produced real drift: a metrics annotation referencing a values path
that no longer existed, one stray TLS cert name off the sibling pattern, a dbSchema comment
copied into entries where it was meaningless, and a complete apps readiness: block configuring a
probe the template never rendered. Helm itself cannot fix this in values: sibling keys are never
merged into map entries — values-layer merging is only file-over-file on the same path.
Decisions
Section titled “Decisions”-
Type-level defaults, merged template-side. Top-level
appDefaults:andserviceDefaults:sit besideapps:/services:invalues.yaml. Every template that ranges over a map resolves the entry’s effective config first, via one helper per type (emergent.app.config,emergent.service.config):mustMergeOverwrite (deepCopy <defaults>) <entry>, rebinding the range variable. The chart stays fully self-contained under plainhelm template; nothing about the mechanism depends on helmfile. Top-level placement (not a_defaultssentinel inside the map, not aninstances:restructure) keeps existingapps.<name>override paths stable and the range loops free of sentinel-skipping. -
A per-entry key must be a genuine difference — uniform keys live only in defaults, even in the base chart. This is the discipline that makes environment-level defaults work: the merge runs after all values layers, so a per-entry key always beats
appDefaults, including anappDefaultsoverride set by an environment file. A base-chart entry restating a default would silently shadow every environment’s type-level override of that key. -
Defaults are schema-complete and are the config surface’s documentation.
appDefaults/serviceDefaultshold exactly the keys the templates read, with the explanatory comments that were previously copy-pasted per entry. Membership is governed by the “actually used” test: a key (or rendered line) with no consumer is deleted, not defaulted. Applying that test removeddebug,extraArgs,projectName, the appsreadiness:block,metricsannotations andserviceMonitorconfig, most of thedev:block (onlydev.main.gatewayIPis read), the appsservice.type/clusterIP/loadBalancerIP/loadBalancerSourceRangeskeys, and the renderedPROJECT_NAME+ downward-API (emergent_*) env vars and probeHostheaders that nothing consumed. -
One hostname key. An app’s external hostname was stored three times per app (
haHost,gateway.hostName,listenerset.host). It is now a single top-level per-apphostName, read by the HTTPRoute, the gateway AuthorizationPolicy, and as the ListenerSet host default (listenerset.hostsurvives only as an optional override via the existingdefaultchain). -
Environment override files use the same shape.
overrides-dev.yamlandoverrides-local.yamlsetappDefaults/serviceDefaultsonce (registry, static-web-server tag, gateway enablement, hostPath dev volumes) plus thin per-entry residue (hostnames, dev ports, service entrypoints and env).
Merge semantics — edges future readers must know
Section titled “Merge semantics — edges future readers must know”- Maps deep-merge; lists replace wholesale (a per-entry or env-layer list supersedes the default list — consistent with Helm’s own values-file layering).
- Setting a per-entry key to
nullin a later values file deletes it at the values layer, so the entry falls back to the type default. This must be done per-entry (seeoverrides-local.yaml’slocal.frontendPort: null): hoisting anullintoappDefaultscannot delete a per-entry value, because per-entry keys win the template-side merge. - Falsy-but-set values (
enabled: false,"",0) do override defaults; only absent keys fall through.
Alternatives rejected
Section titled “Alternatives rejected”- Status quo (every entry self-contained): greppable, but the drift listed above is the empirical cost, and it recurs with every new app/service.
- Helmfile-layer expansion: logic in the wrong layer; chart unusable without helmfile.
- YAML anchors/merge keys: shallow-only, single-file — environments get nothing.
- Convention-derived identity (tpl-rendered
{{ .appId }}-datarepositories, cert names, hostnames): the real data already breaks every candidate convention (docsEngkey vsdocs-engname; hostnames not derivable from app keys), so it degenerates to explicit overrides plus indirection. Identity stays explicit literals.
Consequences
Section titled “Consequences”values.yamlwent from ~1,190 to ~390 lines;overrides-dev.yamlfrom 303 to 119;overrides-local.yamlfrom 104 to 46. Adding an app is now ~10 lines (hostname, cert, image repository) instead of ~170.- Verification for chart changes of this kind is golden-render diffing:
helm template(bare, and with each environment values stack) before and after, with any diff limited to enumerated, intended deltas.