OnCallReady

Lesson 25.24 · CI/CD Pipelines & Helm · 20 min read

Templates: Go templates, named templates, tpl and the checksum trick

In plain words

Imagine a birthday invitation with blanks: "Dear ___, come to my party at ___". A mail-merge machine fills the blanks from a list and prints one card per friend. It can also do small tricks: "if the friend is under 6, add a line about parents", "repeat this line for every game we will play". And it has stamps you define once, like your address block, that you can press onto any card.

Helm templates are that machine. {{ .Values.x }} fills a blank, if, range and with are the tricks, and define in _helpers.tpl makes the stamps you use with include. toYaml | nindent 12 pastes a whole block at the right indent. The checksum trick stamps a hash of the ConfigMap on the pod, so a config change automatically rolls the pods.

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

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

Why it helps

Rendering bugs are what break a chart after a harmless-looking change. When the pipeline fails with YAML parse error ... line 28: did not find expected key, you know it is an nindent with the wrong number or a missing {{-, and you render with --debug to look at line 28. When it fails with nil pointer evaluating interface {}.tag, someone set image: null or misspelled imgae.

The checksum annotation solves a real incident class: pods keep running stale config after a ConfigMap change, because nothing in the pod template changed. Selector labels that include a version break every upgrade with field is immutable. These are the kind of findings you add in a chart review, and platform base charts are full of include, tpl and required. Interviews ask about include vs template and how to roll pods on config change.

FAQ

What is the difference between include and template?

Both render a named template from define. template writes its output directly and cannot be used in a pipeline, so {{ template "x" . | nindent 4 }} is an error. include returns the output as a string, so you can pipe it into nindent, quote or sha256sum. In Helm charts, always use include. The second argument is the scope the named template sees, usually . or $ inside a loop.

Why can't I access .Values inside a range or with block?

Because range and with change the dot. Inside range $k, $v := .Values.extraEnv, . is the current element, so .Values does not exist on it. Use $, which always refers to the root context: {{ $.Values.x }} or {{ $.Release.Name }}. The same applies when calling include inside a loop: pass $, not ., if the named template needs the root.

Why do my pods not restart when I change a ConfigMap in the chart?

A Deployment only rolls when its pod template changes. Updating the ConfigMap leaves the pod template identical, so the pods keep the old values: environment variables are read only at start, and mounted files update with a delay while the app may not reload them. Add an annotation with a hash of the rendered ConfigMap to the pod template, checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}, so any config change triggers a normal rolling update.

Why does default not work when my value is 0 or false?

default treats every empty value as missing, and in Go templates empty includes 0, false, "", nil, and empty maps and lists. So {{ .Values.replicas | default 2 }} turns an explicit 0 into 2, and if .Values.enabled is false for false. When 0 or false are meaningful, test with hasKey .Values "replicas" or kindIs "invalid" to distinguish "not set" from "set to zero".

When should I use tpl?

When a value itself needs to contain template expressions, for example gatewayUrl: "http://gateway.{{ .Release.Namespace }}.svc:8443". {{ tpl .Values.config.gatewayUrl . }} renders it; without tpl the braces land literally in the output. Use it only on a few documented fields, since it makes values executable and harder to reason about. Base charts often support it for annotations or extra manifests.

In an interview Mid

How do you make pods roll automatically when their configuration changes?

Changing a ConfigMap does not restart the pods that read it - the Deployment's pod template did not change, so nothing rolls and the pods keep the old config.

The Helm fix is the checksum annotation on the pod template:

spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}

include renders the ConfigMap template to text and sha256sum hashes it. Any config change changes the hash, the pod template differs, and the Deployment does a normal rolling update with its readiness checks - no rollout restart step in the pipeline.

Limits: it only sees what the chart renders. A Secret created outside the chart needs a controller that watches it and restarts the pods (Reloader) or an explicit restart.

Related rules: use include (pipeable), not template; never put anything that changes between releases into selector labels - spec.selector is immutable and the upgrade fails with field is immutable.

Also asked: How do you debug a Helm chart that fails to render or produces wrong YAML? · What is the difference between include and template in a Helm chart? · Why must a Deployment's selector labels never contain the version?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.