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
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:
| Message | Cause |
|---|---|
function "foo" not defined | Typo, 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 key | Rendered output is not valid YAML — almost always indentation |
at <include "x" .>: error calling include | The 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.