# 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: ```yaml 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. --- ## οΏ½ 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 ```yaml # 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`. --- ## οΏ½πŸ” Before Creating Anything New **ALWAYS search the workspace for existing implementations first.** Before writing a new template, config, or resource: 1. Search the workspace for similar existing files 2. If found, migrate/adapt it β€” don't invent a new structure 3. 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. ```yaml # ❌ 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: ```yaml metadata: namespace: {{ required "global.targetNamespace is required β€” set the target namespace in values..yaml" .Values.global.targetNamespace }} ``` ### Release Name Never hardcode a release name. Use `.Release.Name` to derive resource names: ```yaml # ❌ 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..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:** 1. **Static baseline** β€” identical for every installation. If a value varies between installations, it does not belong here. 2. **Umbrella glue** β€” wires subcharts into a coherent whole. Shared values like `global.domain` are 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..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.` β€” 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..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. ```yaml # βœ… Sensible default β€” hardcoded in template, not in values.yaml replicas: 1 # βœ… Must vary per installation β€” required, set in values..yaml host: {{ required "global.domain is required β€” set it in values..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`: ```yaml # 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) ```yaml 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.): 1. Put the config file in `files/` directory 2. Create a ConfigMap that reads it with `.AsConfig` + `tpl` 3. Use `{{ .Values.global.xxx }}` expressions inside the config file for dynamic values ```yaml # templates/configmap.yaml kind: ConfigMap apiVersion: v1 metadata: name: my-component-config data: {{ (tpl (.Files.Glob "files/*").AsConfig . ) | nindent 2 }} ``` ```yaml # 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:** ```yaml # 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: ```yaml # templates/configmap.yaml kind: ConfigMap apiVersion: v1 metadata: name: dashboards data: {{ (tpl (.Files.Glob "files/dashboards/*").AsConfig . ) | nindent 2 }} ``` ```json # 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 (`*`, `{`, `}`) β†’ `.AsConfig` handles escaping safely ```yaml # 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: ```bash 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..yaml` | IAC / deployment repo | installation-specific overrides and required parameters | - `values.yaml` contains defaults valid for every installation; it never contains installation-specific data - `values..yaml` provides what that specific installation requires β€” credentials, hostnames, sizing, feature flags - Required values (no sensible default) use `required` in templates and have **no entry** in `values.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.` 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. ```yaml # 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 ```yaml # ❌ 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..yaml` β€” not to `values.yaml`, which is shared across all installations. ```yaml # βœ… Start here β€” hardcoded in template resources: requests: cpu: "100m" memory: "128Mi" # βœ… When developer asks to parameterize β€” goes in values..yaml, not values.yaml resources: requests: cpu: {{ required "resources.requests.cpu is required β€” set it in values..yaml" .Values.resources.requests.cpu }} memory: {{ required "resources.requests.memory is required β€” set it in values..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. ```bash # 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.Name` or `.Release.Namespace` - Manifest links between resources (e.g. Service β†’ Deployment) use the correct names and namespaces - No `Error:` lines - `required` validation fires when values are missing