The problem
The chart you installed so far only filled in numbers. Real charts also need logic: "add this block only if it is set", "one env entry per item", "roll the pods when the config changes". That is the template language. You will read it far more often than write it - in every chart you install - so the goal is to read it fluently and fix the usual rendering bugs.
What you need to know already: 25.19 (chart, render, helm template), 25.21 (values), 25.23 (the config change that did not reach the pods), 15.16 (pod template, rolling update), 15.26 (labels, selectors, annotations).
Words for this lesson
- action - anything between
{{ }}in a template; the rest is copied as is. - function - a named operation, like
quoteordefault. - pipeline (template sense, not CI) -
A | f: pass A into function f. - scope (the dot) - the object
.currently refers to. - named template - a snippet defined once with
defineand reused.
The language in ten minutes
A template is text with actions in {{ }}. Helm uses Go's text/template library plus Sprig (a big library of extra functions) and a few of its own.
{{ .Values.replicaCount }} print a value
{{ .Values.image.tag | default "1.0" }} pipeline: the left side becomes the LAST argument
{{ quote .Values.config.profile }} same as .Values.config.profile | quote
{{- .Values.x }} {{- trims ALL whitespace (incl. newlines) to the left
{{ .Values.x -}} -}} trims to the right
{{/* a comment */}}
The dot is the current scope. At the top of a template it is the root object: .Values, .Release (Name, Namespace, Revision, IsInstall, IsUpgrade, Service), .Chart (the Chart.yaml fields, capitalised: .Chart.AppVersion), .Capabilities (KubeVersion, APIVersions.Has), .Files (non-template files in the chart), .Template (Name, BasePath).
with X runs its body only if X is non-empty, with the dot set to X. range loops over a list or map (here $name, $value := names each key and value). Both change the dot; inside them, $ is always the root:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 4 }} # . is podAnnotations now
{{- end }}
env:
{{- range $name, $value := .Values.extraEnv }} # maps iterate in SORTED key order
- name: {{ $name }}
value: {{ $value | quote }}
# {{ .Values.x }} here would fail: . is the map value. Use {{ $.Values.x }}
{{- end }}
{{ if X }} ... {{ end }} keeps its body only if X is true. if is false for: false, 0, "", nil, an empty map or list. So {{ if .Values.replicaCount }} is false for 0 - which is why charts that must allow 0 replicas test {{ if not (kindIs "invalid" .Values.replicaCount) }} ("is it set at all?") or use hasKey.
The functions you actually use
Read each line as "function, arguments: what it returns". nindent and indent matter because YAML meaning depends on indentation.
default "x" .Values.a a if it is non-empty, else "x" (0 and false count as empty!)
required "msg" .Values.a fail rendering with msg if a is empty
quote / squote "a" / 'a' - quote every string that could look like a number/bool
toYaml .Values.resources a map as YAML - always followed by nindent
nindent 12 newline + indent every line by 12
indent 4 indent without the leading newline
include "name" . a named template's output, as a STRING you can pipe
tpl .Values.x . render a value as a template (values that contain {{ }})
printf "%s-%s" a b formatting
trunc 63 | trimSuffix "-" names Kubernetes accepts (at most 63 chars, no trailing -)
b64enc, sha256sum, lower, upper, replace, contains, hasPrefix, split, list, dict, merge
fail "msg" stop with an error
lookup "v1" "Secret" ns name read a live object - EMPTY in helm template and --dry-run=client
The toYaml ... | nindent N pattern is where most rendering bugs are: the number must be the column where the block's keys should start, and the action itself must begin with {{- so the newline before it is eaten:
resources:
{{- toYaml .Values.resources | nindent 12 }}
Named templates
_helpers.tpl holds define "NAME" ... end blocks. They are global across the chart and all its subcharts, which is why every name is prefixed with the chart name:
{{- define "payments-api.fullname" -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "payments-api.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Use them with include, never template: template writes its output directly and cannot be piped, so {{ template "x" . | nindent 4 }} is a parse error while {{ include "x" . | nindent 4 }} works. The second argument is the scope the named template sees - pass . (or $ inside a range).
Selector labels (the labels a Deployment uses to find its pods, 15.26) deserve their own helper and must never contain anything that changes between releases (version, chart version): a Deployment's spec.selector is immutable, and a chart that puts the app version in it fails every upgrade with field is immutable.
tpl: templates inside values
Sometimes a value needs to refer to other values or to the release:
# values.yaml
config:
gatewayUrl: "http://gateway.{{ .Release.Namespace }}.svc:8443"
PAYMENTS_GATEWAY_URL: {{ tpl .Values.config.gatewayUrl . | quote }}
tpl VALUE . renders VALUE as a template, with . as its scope. Without tpl, the braces land in the ConfigMap literally. Platform base charts use tpl on a few documented fields; do not sprinkle it everywhere - it makes values executable.
The checksum annotation
The incident in 25.23 (the ConfigMap changed but the pods kept the old config), solved in the chart. An annotation (15.26) is a free-form note on an object:
spec:
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
$.Template.BasePath is the chart's templates directory; include renders the ConfigMap template to text and sha256sum hashes it. The Deployment's pod template now contains a hash of the rendered ConfigMap. Any change to the config changes the hash, the pod template differs, and the Deployment rolls - through the normal rolling update with its readiness checks. No rollout restart in the pipeline, no pods running stale config. The same pattern works for a Secret the chart renders (hash that template). It does not see Secrets created outside the chart - for those, a controller that watches the Secret and restarts the pods (Reloader is a popular one) or a restart are the answer.
Debugging renders
helm template r ./chart -f prod.yaml -s templates/deployment.yaml one file
helm template r ./chart --debug on a YAML error, prints the broken output anyway
helm lint ./chart -f prod.yaml templates + schema + Chart.yaml sanity
helm install r ./chart --dry-run=server renders with lookup and validates against the API server (applies nothing)
The error messages are precise once you can read them:
Error: template: payments-api/templates/deployment.yaml:21:20: executing
"payments-api/templates/deployment.yaml" at <.Values.image.tag>: nil pointer
evaluating interface {}.tag
File, line, column, and the expression. nil pointer = "tried to read a field of something that does not exist": .Values.image does not exist (a values file set image: null, or misspelled imgae:). And a YAML error after rendering:
Error: YAML parse error on payments-api/templates/deployment.yaml: error converting
YAML to JSON: yaml: line 28: did not find expected key
means the template rendered but the indentation is wrong - almost always an nindent with the wrong number or a missing -. Render with --debug and look at line 28 of the output.
What you can now do
- Read a chart's templates: values, pipelines,
if,with,range,$. - Use
include,toYaml | nindent,requiredandtplcorrectly. - Add a checksum annotation so config changes roll the pods, and debug a broken render.