The Template Language

Go templates as Helm uses them: the built-in objects, actions and whitespace control, and why indentation is where most rendering failures live.

intermediate 20 min lesson hands-on task included

Helm templates are Go’s text/template with the Sprig function library added. Two things follow from that, and both matter: the engine has no idea it is producing YAML, and everything it does is string manipulation.

That is why indentation is a correctness concern rather than a style one.


Topic 1: Actions and the Two Delimiters

Everything between {{ and }} is an action — evaluated and replaced. Everything else is copied verbatim.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "checkout.fullname" . }}
  labels:
    {{- include "checkout.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}

Three forms you will use constantly:

{{ .Values.name }}              print a value
{{- if .Values.enabled }}       control flow
{{/* a comment */}}             not rendered, not in the output

The trailing . in include "checkout.labels" . is the context being passed. Forgetting it is one of the most common errors, and produces nil pointer evaluating interface {} because the named template receives nothing.


Topic 2: The Five Built-In Objects

.Values — the merged values tree.

.Release — information about this installation:

{{ .Release.Name }}        the release name
{{ .Release.Namespace }}   where it is going
{{ .Release.Revision }}    1 on install, N on upgrade
{{ .Release.IsInstall }}   true on first install
{{ .Release.IsUpgrade }}   true otherwise
{{ .Release.Service }}     "Helm"

.Release.IsUpgrade is what lets a hook behave differently on a fresh install than on an upgrade — running a seed job only the first time, for instance.

.Chart — the contents of Chart.yaml. {{ .Chart.Name }}, {{ .Chart.Version }}, {{ .Chart.AppVersion }}. The standard image-tag pattern uses it as a default:

image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

.Capabilities — what the target cluster supports. This is how one chart serves several cluster versions:

{{- if .Capabilities.APIVersions.Has "autoscaling/v2" }}
apiVersion: autoscaling/v2
{{- else }}
apiVersion: autoscaling/v2beta2
{{- end }}
{{ .Capabilities.KubeVersion.Version }}    # v1.29.4
{{ .Capabilities.KubeVersion.Minor }}      # 29

One caveat worth knowing before you rely on it: with helm template (no cluster contact) .Capabilities reports Helm’s built-in defaults, not your cluster’s. Use --api-versions to simulate, or --dry-run=server for the truth.

.Files — access to non-template files in the chart, which is how a config file becomes a ConfigMap without being pasted into YAML:

data:
  {{- (.Files.Glob "config/*.conf").AsConfig | nindent 2 }}
  nginx.conf: |
    {{- .Files.Get "config/nginx.conf" | nindent 4 }}
  cert.pem: {{ .Files.Get "certs/ca.pem" | b64enc }}

.Files cannot read anything in templates/, and cannot escape the chart directory.


Topic 3: Whitespace Control

A PIPELINE READS LEFT TO RIGHT — EACH STAGE TAKES THE PREVIOUS RESULT .Values.name my-app | default "app" my-app (unchanged) | trunc 63 label-safe length | quote "my-app" WHITESPACE CONTROL — THE DASH EATS THE SPACE ON THAT SIDE WITHOUT — blank lines break the YAML metadata: {{ if .Values.annotations }} annotations: {{ end }} → stray blank lines where the actions were WITH — clean output metadata: {{- if .Values.annotations }} annotations: {{- end }} → exactly the lines you intended THE INDENTATION RULE THAT CAUSES MOST RENDER FAILURES resources: {{- toYaml .Values.resources | nindent 4 }} nindent adds a newline first, then indents. indent does not. Using indent where you needed nindent is the classic broken-YAML bug. toYaml turns a values map into YAML; nindent puts it at the right depth.
The pipeline at the top is the easy half. The two panels below are the reason `helm template` output so often fails to parse — and the nindent rule at the bottom is the single most common fix.

The template engine leaves behind whatever text surrounded an action, including the newline. In YAML, that produces stray blank lines and broken indentation.

{{- ... }}    trim whitespace BEFORE this action, including the preceding newline
{{ ... -}}    trim whitespace AFTER
{{- ... -}}   both

Without the dashes:

metadata:
  {{ if .Values.annotations }}
  annotations:
  {{ end }}

renders as:

metadata:

  annotations:

Two blank lines where the actions were. With {{- on each, the output is exactly the two intended lines. The convention is {{- on the left of every control-flow action, and it is worth applying mechanically rather than case by case.

indent versus nindent is the other half:

resources:
  {{- toYaml .Values.resources | nindent 4 }}

toYaml converts a values map into YAML text. indent 4 prefixes every line with four spaces. nindent 4 adds a newline first, then indents — which is what you need when the action follows a key on the previous line. Using indent where nindent was needed jams the first line onto the key and produces YAML that will not parse.

# WRONG — the first line lands on the same line as "resources:"
resources: {{ toYaml .Values.resources | indent 4 }}

# RIGHT
resources:
  {{- toYaml .Values.resources | nindent 4 }}

Topic 4: Variables and Scope

{{- $fullName := include "checkout.fullname" . -}}
{{- $svcPort := .Values.service.port -}}

metadata:
  name: {{ $fullName }}

Variables are declared with := and reassigned with =. They are scoped to the block they are declared in, which matters inside range.

$ is always the root context, and it is the fix for the most common scoping error:

{{- with .Values.ingress }}
  host: {{ .host }}                  # . is now .Values.ingress
  release: {{ $.Release.Name }}      # $ escapes back to the root
{{- end }}

Inside with and range, . is rebound. Without $, .Release is not reachable and you get a nil-pointer error that names a field you can plainly see in your values file — which is why this one wastes so much time before you know the rule.


Topic 5: Reading Template Errors

Error: template: checkout/templates/deployment.yaml:23:14:
  executing "checkout/templates/deployment.yaml" at <.Values.image.tag>:
  nil pointer evaluating interface {}.tag

Read it in three parts: file and line, the expression that failed, why. nil pointer evaluating interface {}.tag means .Values.image itself was nil — not .tag. The parent is missing, usually because the key is absent from values.yaml entirely or a with block rebound the dot.

The other messages you will meet:

MessageCause
function "foo" not definedTypo, or a Sprig function that does not exist
wrong type for value; expected string; got map[string]interface {}Passing a map where a string is wanted — usually a missing toYaml
error converting YAML to JSON: did not find expected keyRendered output is not valid YAML — almost always indentation
at <include "x" .>: error calling includeThe named template itself failed; read the second line
template: no template "checkout.labels" associated with template "gotpl"The name in include does not match the define

The workflow that resolves all of them fastest:

helm template . --debug 2>&1 | head -60      # computed values + the failure
helm template . --show-only templates/deployment.yaml
helm lint .

--show-only renders one file, which turns “somewhere in eleven templates” into one file to read.


Topic 6: Two Patterns Worth Copying

Roll pods when config changes. Kubernetes does not restart pods when a ConfigMap changes; hashing the config into a pod annotation does, because it changes the pod template:

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

Every chart worth using does this, and its absence is why “I changed the ConfigMap and nothing happened” is such a common report.

Fail loudly on missing required input:

image:
  repository: {{ required "image.repository is required" .Values.image.repository }}

required stops rendering with your message instead of producing a manifest with an empty field, which the API server may accept and which then fails at runtime in a much less obvious way.

Try it yourself: take a working template, remove every - from its actions, and render it. The output is instructive — you can see precisely which blank line each dash was suppressing, and after that the convention stops feeling arbitrary.

Common mistake: debugging a rendering failure by editing the template and re-running helm install. Install talks to a cluster, is slow, and mixes template errors with API errors. helm template renders locally in under a second and shows you the same failure — do all template debugging there, and only then involve a cluster.