New subsection "Multiple Files in ConfigMap data": - start with inline range for few files (readable in template) - shift to files/ + .AsConfig + tpl when >3 files (readability suffers) - explains threshold for when to switch patterns - shows both inline and file-based examples - enables template variables in file-based approach
16 KiB
Helm Chart Development Guidelines
🚨 Audit Checklist — Violations to Find When Reviewing
When asked to review or analyze a Helm project, actively search for each of these. Do not rely on a quick read — scan template files for the literal patterns below. A violation must be reported as an issue, not ignored.
Hardcoded namespaces
Search all files under templates/ for a literal namespace: key. Flag any value that is not {{ .Release.Namespace }} or a required/.Values expression.
grep -r "namespace:" templates/
Every hit that is not:
namespace: {{ .Release.Namespace }}
namespace: {{ required "..." .Values.something }}
namespace: {{ .Values.something }}
is a violation.
Required values with empty defaults
Search for required calls paired with a fallback default in values.yaml:
grep -r "required" templates/
For each hit, check if values.yaml contains an entry for that key. If it does with a non-empty or placeholder value, it is a violation — required values must have no entry in values.yaml.
values.yaml keys not used in any template
grep -r "\.Values\." templates/
Compare the set of .Values.* references in templates against all keys defined in values.yaml. Keys in values.yaml that no template references are dead weight — flag them.
<EFBFBD> values.yaml Is Not Rendered
values.yaml is static YAML — it is not processed by Helm's template engine. Template expressions like {{ .Release.Name }} or {{ .Values.foo }} do not work in values.yaml and must never be placed there.
Consequences:
- Resource names passed to subcharts via values.yaml must be literal strings
- When a subchart needs to reference a resource created by the parent (e.g. a ConfigMap name), that name must be hardcoded in values.yaml and matched exactly in the parent template
- This is the correct pattern — not a violation
# values.yaml — correct: fixed name because values.yaml cannot template
configMap:
name: custom-alloy-config # matches the name in templates/alloy-config.yaml
# templates/alloy-config.yaml — the parent creates the ConfigMap with this exact name
metadata:
name: custom-alloy-config
Document fixed names with a comment explaining why they are fixed. The audit checklist only flags hardcoded names in templates/ files — never in values.yaml.
<EFBFBD>🔍 Before Creating Anything New
ALWAYS search the workspace for existing implementations first.
Before writing a new template, config, or resource:
- Search the workspace for similar existing files
- If found, migrate/adapt it — don't invent a new structure
- Only create from scratch if nothing exists
🚫 Never Hardcode Namespace, Release Name, or Installation-Specific Values
A Helm chart must be fully agnostic about where and how it is installed.
This is a hard rule — no exceptions.
Namespace
Never hardcode a namespace in any template. The namespace is set by the deployer at install time.
# ❌ FORBIDDEN — always
metadata:
namespace: my-app
namespace: production
namespace: default
# ✅ Required — or omit namespace entirely and let Helm inject it
metadata:
namespace: {{ .Release.Namespace }}
If a resource must reference another namespace (e.g. a NetworkPolicy peer), expose it as a required value:
metadata:
namespace: {{ required "global.targetNamespace is required — set the target namespace in values.<instance>.yaml" .Values.global.targetNamespace }}
Release Name
Never hardcode a release name. Use .Release.Name to derive resource names:
# ❌ FORBIDDEN
name: my-app-service
name: myapp-ingress
# ✅ Required
name: {{ include "mychart.fullname" . }}
# or
name: {{ .Release.Name }}-service
Why — IaC Principle
The chart is infrastructure code. It must be deployable to any cluster, any namespace, any environment, by any deployer — without editing the chart itself. Installation-specific decisions belong exclusively in values.<installation>.yaml in the deployment repo.
In practice: In umbrella charts, you typically deploy each release to a different namespace, or at most two instances to separate clusters. You never deploy two instances of the same umbrella to the same cluster and namespace — that would be a misconfiguration. This constraint is not enforced by the chart; it is part of the deployment discipline.
❌ Wrong: chart decides where it lives
✅ Right: deployer decides where it lives — chart describes what it is
🏠 Resource Ownership
Every Kubernetes resource must have exactly one owner. Never duplicate.
- If a chart creates a resource (e.g. Traefik creates IngressClass), don't create it elsewhere
- Before adding a template for a resource, verify no other chart/subchart already manages it
❌ Wrong: subchart A creates IngressClass AND subchart B creates IngressClass
✅ Right: one chart owns IngressClass, others do not touch it
🧹 values.yaml — Keep It Short and Identical Across Installations
values.yaml serves two purposes:
- Static baseline — identical for every installation. If a value varies between installations, it does not belong here.
- Umbrella glue — wires subcharts into a coherent whole. Shared values like
global.domainare defined once here and consumed by multiple subcharts, so the umbrella behaves as a single unit rather than a collection of independent charts.
Installation-specific values go exclusively in values.<instance>.yaml in the IaC/deployment repo.
The goal is a short, minimal values.yaml. The longer it gets, the harder it is to see what actually varies. When in doubt whether a value belongs in values.yaml, it probably doesn't.
A value may only appear in values.yaml if:
- At least one template references
.Values.<key>— and - It is either shared glue across subcharts, or overrides an upstream subchart default that would otherwise be wrong for all installations
Forbidden:
- Empty string placeholders (
key: "") — these are documentation pretending to be config; use README instead - Defaults for values that are hardcoded in templates — they create a false impression the value is configurable
- Installation-specific data of any kind — hostnames, credentials, sizing, feature flags
- Required values (validated with
required) — absence must trigger the error, not a silent empty default
Two-file layering in IaC:
| File | Location | Content |
|---|---|---|
values.yaml |
chart repo | minimal static baseline, same for all installations |
values.<instance>.yaml |
IaC/deployment repo | everything that varies per installation |
✅ required vs defaults
When a template needs a value, there are exactly two valid patterns:
1. The value has a sensible default — hardcode it directly in the template. Do not add it to values.yaml.
2. The value must come from the deployer — use required with a descriptive error message. Do not put any entry in values.yaml for this key.
| default in templates is forbidden. Adding defaults to values.yaml for values that rarely change is also forbidden — it makes values.yaml long and hard to maintain. Hardcoded defaults in templates are easy to find and easy to parameterize later if needed.
# ✅ Sensible default — hardcoded in template, not in values.yaml
replicas: 1
# ✅ Must vary per installation — required, set in values.<installation>.yaml
host: {{ required "global.domain is required — set it in values.<installation>.yaml" .Values.global.domain }}
# ❌ Forbidden — default in values.yaml adds noise, bloats values.yaml
replicas: {{ .Values.replicas }} # values.yaml: replicas: 1
# ❌ Forbidden — hides missing config, fails later in an obscure way
host: {{ .Values.global.domain | default "" }}
replicas: {{ .Values.replicas | default 1 }}
The error message in required must tell the deployer exactly what to set and where. "X is required" alone is not enough — say what value is expected and in which file.
📦 Subchart Dependency Conditions
Use condition: in Chart.yaml to enable/disable components. Use flat booleans, not nested .enabled:
# Chart.yaml
dependencies:
- name: keycloak
repository: "file://../components/keycloak"
version: "0.2.0"
condition: components.keycloak
# values (Good)
components:
keycloak: true
traefik: false
# values (Avoid)
keycloak:
enabled: true
⏱️ Hook Ordering
Resources that depend on other resources must use helm.sh/hook with hook-weight to ensure correct install order.
Established ordering convention:
hook-weight: "10"— infrastructure prerequisites (e.g. ClusterIssuer, IngressClass)hook-weight: "15"— resources that depend on prerequisites (e.g. Certificate)
metadata:
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-weight": "10"
📄 Configuration Files over values.yaml
Prefer files/ + ConfigMap over defining configuration in values.yaml.
When a component needs configuration (CASC, realm config, ini files, etc.):
- Put the config file in
files/directory - Create a ConfigMap that reads it with
.AsConfig+tpl - Use
{{ .Values.global.xxx }}expressions inside the config file for dynamic values
# templates/configmap.yaml
kind: ConfigMap
apiVersion: v1
metadata:
name: my-component-config
data: {{ (tpl (.Files.Glob "files/*").AsConfig . ) | nindent 2 }}
# files/config.yaml — contains Helm template expressions
server:
url: https://{{ .Values.global.domain }}
env: {{ .Values.global.environment }}
Why: Config files are readable, diffable, and version-controlled as real files. Large configs in values.yaml become unmaintainable and hard to review.
Multiple Files in ConfigMap data
When a ConfigMap needs multiple files as separate keys (e.g. dashboards, rules):
Start with inline range:
# Few files — use inline range
data:
{{- range $path, $_ := .Files.Glob "files/dashboards/*.json" }}
{{ base $path }}: {{ $.Files.Get $path | quote }}
{{- end }}
Shift to files + tpl when:
- You have many files (>3) — readability suffers in the template
- Files need Helm variables —
{{ .Values.xxx }}expressions must be processed
When you cross this threshold, move the files to a directory and use .AsConfig + tpl to process them:
# templates/configmap.yaml
kind: ConfigMap
apiVersion: v1
metadata:
name: dashboards
data: {{ (tpl (.Files.Glob "files/dashboards/*").AsConfig . ) | nindent 2 }}
# files/dashboards/dashboard.json — can now contain {{ .Values.xxx }} expressions
{
"namespace": "{{ .Release.Namespace }}",
"alertmanager": "{{ .Values.alertmanager.host }}"
}
When .AsConfig + tpl is required:
- Config file contains
{{ }}template expressions → always use.AsConfig+tpl - Config file contains special characters (
*,{,}) →.AsConfighandles escaping safely
# Never use plain Files.Get on files with template expressions or special chars:
{{ tpl (.Files.Get "files/config.json") . }} # ❌ breaks on * characters
# Always use AsConfig:
{{ tpl (.Files.Glob "files/config.json").AsConfig . }} # ✅
🔄 Dependency Caching
After editing any subchart template or values, cached charts in charts/ will be stale:
rm -rf charts/ Chart.lock
helm dependency update .
Always rebuild before helm template testing when subchart files have changed.
🎂 Umbrella Chart Pattern
The standard structure is an umbrella chart that integrates multiple component charts.
my-umbrella/
├── Chart.yaml ← declares component charts as dependencies
├── values.yaml ← static baseline: shared across all installations
├── templates/ ← only cross-cutting resources that no subchart owns
└── charts/ ← built by helm dependency update
Two-file values layering:
| File | Where it lives | Purpose |
|---|---|---|
values.yaml |
umbrella chart repo | static baseline, same across all IAC installations |
values.<installation>.yaml |
IAC / deployment repo | installation-specific overrides and required parameters |
values.yamlcontains defaults valid for every installation; it never contains installation-specific datavalues.<installation>.yamlprovides what that specific installation requires — credentials, hostnames, sizing, feature flags- Required values (no sensible default) use
requiredin templates and have no entry invalues.yaml— their absence triggers a clear error at install time
🔗 Glue Configuration: Coordinating Subchart Dependencies
When a subchart cannot use Helm template expressions (e.g. reads values.<key> directly instead of templating), the parent chart may coordinate resource names as "glue configuration."
When this pattern is appropriate:
- Subchart hardcodes a config path or resource name lookup (cannot be changed)
- Parent chart must create that resource with the exact name the subchart expects
- This is documented in both parent and subchart values.yaml with clear reasoning
- There is no other way to make the subchart work
Example: Tempo + MinIO
Tempo chart cannot use {{ .Release.Name }} in values.yaml. It reads the MinIO hostname directly from config.
# values.yaml — MinIO
minio:
fullnameOverride: "minio" # ← Forces service name to "minio"
# Documented trade-off: prevents multiple releases in same namespace
# but enables Tempo to connect
# values.yaml — Tempo
tempo:
storage:
s3:
endpoint: "http://minio:9000" # ← Hardcoded to match MinIO fullnameOverride
⚡ KISS — Start Minimal, Add Only When Asked
Do not add knobs, flags, or options that aren’t needed right now.
- Build the minimum that meets the stated functional requirement
- Do not anticipate future needs with extra values, conditions, or template logic
- Complexity is easy to add; it is hard to remove once deployed
- AI may suggest what might be needed later — but must not implement it without explicit instruction
# ❌ Wrong: adding a flag “just in case”
features:
enableFutureThing: false # not needed yet, adding anyway
# ✅ Right: don’t add it. If it’s needed, the developer will ask.
When in doubt, leave it out. A value that exists but is never used is noise. A template condition that is never toggled is dead code. Raise the question with the developer instead of silently implementing it.
Hardcoded values in templates are the starting point
Not every value needs to be configurable. Making everything a parameter is an anti-pattern — it creates noise and makes charts harder to understand.
Start with hardcoded values in the template. When the developer asks for a value to be parameterized, move it to values.<instance>.yaml — not to values.yaml, which is shared across all installations.
# ✅ Start here — hardcoded in template
resources:
requests:
cpu: "100m"
memory: "128Mi"
# ✅ When developer asks to parameterize — goes in values.<instance>.yaml, not values.yaml
resources:
requests:
cpu: {{ required "resources.requests.cpu is required — set it in values.<instance>.yaml" .Values.resources.requests.cpu }}
memory: {{ required "resources.requests.memory is required — set it in values.<instance>.yaml" .Values.resources.requests.memory }}
# ❌ Anti-pattern — parameterising everything preemptively
resources:
requests:
cpu: {{ .Values.resources.requests.cpu }} # nobody asked for this yet
memory: {{ .Values.resources.requests.memory }} # adds noise without benefit
🧪 Testing Templates
Always test with at least two different release names and namespaces. This verifies that resource references, names, and dependencies work correctly regardless of where the chart is deployed.
# Test 1: release=app1, namespace=default
helm template app1 . \
--namespace default \
--set global.key=value \
... \
2>&1 | grep "^kind:" | sort | uniq -c
# Test 2: release=app2, namespace=production
helm template app2 . \
--namespace production \
--set global.key=value \
... \
2>&1 | grep "^kind:" | sort | uniq -c
Verify:
- Expected resource kinds appear in both tests
- Resource names, labels, and selectors are correctly formed using
.Release.Nameor.Release.Namespace - Manifest links between resources (e.g. Service → Deployment) use the correct names and namespaces
- No
Error:lines requiredvalidation fires when values are missing