CoursesHelmThe templating engine

The templating engine

Go templates and the release context.

Intermediate14 min · lesson 5 of 12

A blank form letter has the fixed words already printed and a few empty lines waiting for a name, a date, an amount. Photocopy it a thousand times, fill the blanks differently on each run, and every copy comes out correct. A Helm chart's templates work like that. You write the shape of a Deployment, a Service, a ConfigMap once (three common kinds of Kubernetes object, the things that actually run and configure your app), and leave holes where the specifics go: the release name, the image tag, the replica count. When you install or upgrade, Helm's engine (Go's text/template library, the part of the Go programming language built for exactly this) fills every hole from one context object and hands the finished YAML (a plain-text configuration format, short for "YAML Ain't Markup Language") to the Kubernetes API server (Application Programming Interface, the cluster's front door that checks each manifest and decides what to run). Knowing what lives in that context, and how the engine walks it, is the whole job.

The context: one dot to rule them all

Every {{ }} action is evaluated against a root context, written as . and spoken as "dot". Dot is the top of a tree, like the root folder on a disk with everything else branching down from it. Helm hangs a handful of built-in objects off it before your first template ever runs, and they are all TitleCase (their names start with a capital letter). That capitalization is how you tell Helm's objects apart from your own values at a glance: .Release is Helm's, .Values.release would be yours.

.Values holds your merged configuration, the stack of values.yaml plus any --set flags and parent-chart overrides (the values lesson covers how that stack is built). .Chart reads straight from Chart.yaml: .Chart.Name, .Chart.Version, and .Chart.AppVersion. .Release is the interesting one for operators. It is metadata Helm only learns the moment you run it against a target: .Release.Name, .Release.Namespace, .Release.Revision (1 on a fresh install, bumped by one on each upgrade), .Release.Service (always the string "Helm"), and two true/false flags, .Release.IsInstall and .Release.IsUpgrade. .Capabilities describes the cluster you are aiming at: .Capabilities.KubeVersion is the Kubernetes version, and .Capabilities.APIVersions.Has "batch/v1" asks whether a given API is present, so a chart can adapt to what the API server actually offers. .Files reaches non-template files bundled in the chart, handy for pulling in a config file or a certificate. Together these are the release context: data that does not exist until Helm runs against a real cluster or a simulated one.

templates/deployment.yaml
# templates/deployment.yaml (excerpt)
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Chart.Name }} # release name + chart name
labels:
app.kubernetes.io/managed-by: {{ .Release.Service }} # always "Helm"
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
spec:
replicas: {{ .Values.replicaCount }} # straight from values.yaml
selector:
matchLabels:
app: {{ .Chart.Name }}
template:
metadata:
labels:
app: {{ .Chart.Name }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
The render context: children of the dot (.)
.Values
your merged config
values.yaml + --set + parent overrides
.Chart
Name / Version
read from Chart.yaml
AppVersion
the app's own version
.Release
Name / Namespace
known only at run time
Revision
1, then +1 per upgrade
Service
always "Helm"
IsInstall / IsUpgrade
which path you are on
.Capabilities
KubeVersion
target cluster version
APIVersions.Has
is batch/v1 present?
.Files
bundled files
.Get, .Glob, .Lines
Built-in objects are TitleCase. Your own keys live under .Values and are named whatever you called them.

Render before you ship

A dress rehearsal catches the missed cue before opening night. helm template is that rehearsal for your chart. The engine normally runs during a live install or upgrade, and you do not want to first meet a broken template then, with half your manifests (the YAML files that tell Kubernetes what to run) already applied to the cluster. helm template runs the identical engine on your laptop and prints the rendered YAML to standard output, the terminal's normal text stream. No cluster is contacted, no release is recorded. It is the fastest feedback loop you have while writing a chart, and later it becomes the seam where continuous integration (the automated checks that run on every code change, often shortened to CI) scans manifests for policy violations before anything reaches a cluster (the security lesson builds on this).

terminal
helm template web ./mychart --show-only templates/deployment.yaml
output
---
# Source: mychart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-mychart
labels:
app.kubernetes.io/managed-by: Helm
helm.sh/chart: mychart-0.1.0
spec:
replicas: 2
selector:
matchLabels:
app: mychart
template:
metadata:
labels:
app: mychart
spec:
containers:
- name: mychart
image: "nginx:1.25.3"

Because no cluster answers, .Release and .Capabilities fall back to Helm's built-in defaults. A few flags let you shape what the context reports. --namespace prod sets what .Release.Namespace returns. --is-upgrade flips .Release.IsUpgrade to true, so you can render the upgrade path (install is the default). --show-only templates/deployment.yaml renders a single file when a chart emits dozens, which keeps the output readable while you iterate.

terminal
# render every template to stdout, no cluster contacted
helm template myapp ./mychart
# set the namespace that .Release.Namespace reports
helm template web ./mychart --namespace prod
# render one file when a chart emits dozens
helm template web ./mychart --show-only templates/deployment.yaml
# pretend this is an upgrade, so .Release.IsUpgrade is true
helm template web ./mychart --is-upgrade
helm template guesses at the cluster
helm template never contacts a cluster, so .Capabilities.KubeVersion and .Capabilities.APIVersions report Helm's defaults, not your real API server, and the lookup function (which reads live objects from the cluster) returns an empty result every time. A chart that gates a NetworkPolicy (a firewall rule for pods) or a securityContext (the privilege settings on a container) on .Capabilities.APIVersions.Has "..." can render one way in CI and another way on the cluster. When the exact rendered output matters for security, pin the version with --kube-version 1.29 and list real APIs with --api-versions, or render against the actual target using helm install --dry-run=server, which populates capabilities and lookup for real.

Whitespace and the dash

Go's template engine is a literal copier. Everything outside {{ }} comes through exactly as typed, including the newlines and indentation around your actions. Leave a control line alone on its own row and you get a blank line in the result, and YAML, a format where indentation carries meaning, stops forgiving that fast. The dash trims the gap. {{- eats the whitespace and the newline immediately before an action; -}} eats what comes after. Put a - on the inside of the braces wherever a control line would otherwise leave a hole.

templates/configmap.yaml
# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
greeting: hello
{{- if .Values.debug }} # {{- trims the newline + indent before this line
mode: debug
{{- end }}
terminal
helm template web ./mychart --show-only templates/configmap.yaml
output
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: web-config
data:
greeting: hello

With debug unset the whole if block vanishes and leaves no dangling blank line, because the {{- on both the if and the end pulled the surrounding newlines out. Drop those two dashes and you would see an empty, space-filled line sitting where the {{ if }} used to be, and a YAML parser would happily accept it right up until it does not.

When the dot moves

Two control blocks move the dot. range walks a list and with narrows into a value. Opening either one is like stepping into a subfolder: inside, . no longer means the root, it means the current item. So inside {{ with .Values.image }} the dot is the image map, and .repository works, but .Release is gone, because .Release is not a child of the image map. When you need something from the top while you are scoped, reach for $. The engine pins $ to the root context for the whole template, however many blocks deep you are. And close what you open: every range, if, and with needs its own {{ end }}. A missing end is the single most common render failure you will meet.

templates/deployment.yaml
# templates/deployment.yaml (env excerpt)
env:
{{- range .Values.env }}
- name: {{ .name }} # "." is now one env entry, not the root
value: {{ .value | quote }}
{{- end }}
- name: RELEASE
value: {{ $.Release.Name | quote }} # $ is still pinned to the root context
output
env:
- name: LOG_LEVEL
value: "info"
- name: REGION
value: "eu-west-1"
- name: RELEASE
value: "web"
Inside range and with, the dot moves
The number-one templating bug is a nil pointer or a stray <no value> right after you open a range or with. Both rebind . to the current scope, so a reference like .Release.Name that worked one line above now resolves against the wrong object and fails. Whenever you need the root while scoped, use $. Then re-render: the engine names the offending file, line, and column, but only once you actually run it.

What breaks, and how you see it

Helm shows you two different failures, and they mean different things. <no value> is the softer one: you asked for a key that does not exist on a map that does, so the engine prints the literal text <no value> and keeps going, quietly producing YAML that will confuse you or the API server later. A nil pointer (reaching into something that holds nothing) is the harder stop: you went through a parent that is itself missing, like asking for .Values.image.repository when image is null, and the render aborts with the file, line, and column of the offending action. Both are reasons to render early and often instead of waiting for the Kubernetes API server to reject your work after a partial apply.

terminal
helm template web ./mychart --set image=null
output
Error: template: mychart/templates/deployment.yaml:19:21: executing "mychart/templates/deployment.yaml" at <.Values.image.repository>: nil pointer evaluating interface {}.repository
Use --debug flag to render out invalid YAML

Wire helm template into continuous integration and feed its output to a manifest scanner (kubeconform for schema, conftest or kube-score for policy). Then render the same chart at the old revision and the new one and diff the two, so a one-character change in values that quietly drops a securityContext or widens a NetworkPolicy shows up as a reviewable line in the pull request, long before it reaches a node.

Quick check
01In the env excerpt the entries render inside {{- range .Values.env }}, and the last entry sets value: {{ $.Release.Name | quote }}. Why does that line need $ instead of .Release.Name?
Incorrect — A helm template run still populates .Release from Helm's defaults. What breaks here is the scope you are standing in, not the command you ran.
Incorrect — The | quote pipe is what puts the quotes on, which is why the render shows value: "web" on that line.
Correct — Each pass of the loop rebinds the dot to the item being walked, while $ stays pinned to the root for the whole template.
Incorrect — Chart.yaml feeds .Chart. The release name only arrives when Helm runs against a target, and $ simply walks you back to the top.
02helm template web ./mychart --set image=null aborts with nil pointer evaluating interface {}.repository, while a mistyped key elsewhere in the same chart only prints <no value> and the render finishes. What separates the two?
Correct — A soft miss leaves the literal text sitting in your YAML and carries on, while a missing parent stops the render at a named file, line, and column.
Incorrect — --debug changes how much you are shown, not what went wrong. Setting image to null left nothing for the engine to walk through.
Incorrect — It works the other way round, and that ordering matters because the soft failure is the one that quietly ships bad YAML onward.
Incorrect — Either result can come out of either path. What decides which one you get is whether the parent you reached through held anything.
03Your chart wraps a NetworkPolicy in {{ if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}. The CI run of helm template disagrees with what lands on the cluster. What gets you an accurate render?
Incorrect — --debug prints more detail about the same offline render. It cannot supply API versions that Helm never asked any cluster for.
Incorrect — with narrows the dot onto a value and nothing more. The capability data stays whatever the offline run handed the engine.
Incorrect — Dashes only control the blank lines around an action, so trimming tidies the output without touching which APIs get reported.
Correct — Naming the version and the API list, or letting a server dry run fill both in, makes the local output match what the cluster will do.

Try this

Run helm template web ./mychart --show-only templates/deployment.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: helm template guesses at the cluster. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related