Mert Özen Further With Every Line
DevOps Helm — #03

The Helm Template Language: Go Templates, Functions, _helpers.tpl, include, Flow Control and Whitespace

Mert Özen Nov 15, 2026 13 min 7 views
The Helm Template Language: Go Templates, Functions, _helpers.tpl, include, Flow Control and Whitespace

Helm templates are Go text/template plus Sprig and Helm functions; shared labels and names are defined in _helpers.tpl with define and called with include, and when the scope changes the root is reached through $. We add bookstore-web to the Bookstore chart as a second component and look with real output at the checksum annotation that restarts pods on config changes, whitespace and indentation errors, and how tpl and lookup behave in dry runs.

Version note: The examples in this post were run with Helm 4.3.2, kind 0.33 with Kubernetes 1.37, kubectl 1.37, .NET 10 and Angular 22.

Guess First

The API's settings come from a ConfigMap as environment variables through envFrom. You run helm upgrade --set config.storeName="Bookstore (test)", Helm says "upgraded" and kubectl get configmap shows the new value. When you ask the API through /api/info, which store name do you get back? Does the pod restart? The answer is in the ConfigMap section, with the real output of two separate tries.

Pipelines and Common Functions

Helm's template language is Go's text/template package plus extra functions; most come from the Sprig library, a few (include, tpl, required, lookup, toYaml) are Helm's own. Everything between {{ }} is an expression, and the | sign passes the value on its left as the last argument of the function on its right:

image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
storeName: {{ .Values.config.storeName | quote }}
replicas: {{ .Values.replicaCount | default 1 }}

default puts the second value in place when the first is empty (empty string, zero, false, nil, empty list). So if you pass replicaCount: 0 and use default 1, zero is never written; watch out if that is a deliberate choice. quote wraps the value in double quotes: it keeps YAML from turning values such as true, 1.10 or no into something other than a string; environment variable values and annotations must always be strings. required stops the render with a meaningful message when the value is missing. toYaml turns a map or a list into YAML, and nindent N adds a newline in front and indents every line by N spaces. tpl renders a string as a template; this is how you carry template expressions in values. lookup reads a live resource from the cluster; its behaviour changes in the dry run modes, and we look at it separately below.

define, include and _helpers.tpl

In the second post we used the helpers from helm create as they were. In this post bookstore-web arrives as a second component, and the labels need to change per component. We rewrote the helpers for that. Files starting with an underscore (_helpers.tpl) produce no manifests; they only hold templates named with define:

{{/*
Selector labels for one component. Usage: include "bookstore.selectorLabels" (dict "ctx" . "component" "api")
These never change during the life of a release: Deployment selectors are immutable.
*/}}
{{- define "bookstore.selectorLabels" -}}
app.kubernetes.io/name: {{ include "bookstore.name" .ctx }}
app.kubernetes.io/instance: {{ .ctx.Release.Name }}
app.kubernetes.io/component: {{ .component }}
{{- end }}

{{/*
All labels for one component: selector labels plus version and chart information.
*/}}
{{- define "bookstore.labels" -}}
{{ include "bookstore.selectorLabels" . }}
app.kubernetes.io/version: {{ .ctx.Chart.AppVersion | quote }}
app.kubernetes.io/part-of: bookstore
app.kubernetes.io/managed-by: {{ .ctx.Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .ctx.Chart.Name .ctx.Chart.Version | replace "+" "_" | trunc 63 }}
{{- end }}

{{/*
Image reference. Usage: include "bookstore.image" (dict "ctx" . "image" .Values.web.image)
An empty tag falls back to appVersion from Chart.yaml.
*/}}
{{- define "bookstore.image" -}}
{{- $repository := required "image.repository is required" .image.repository -}}
{{- printf "%s:%s" $repository (.image.tag | default .ctx.Chart.AppVersion) -}}
{{- end }}

{{- define "bookstore.podSecurityContext" -}}
runAsNonRoot: true
seccompProfile:
  type: RuntimeDefault
{{- end }}

{{- define "bookstore.containerSecurityContext" -}}
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
  drop: ["ALL"]
{{- end }}

Helm collects every define in the chart into one namespace; a template called labels in a subchart can override yours. That is why the names start with the chart name: bookstore.labels. You can pass a template only one argument; when you need several values, build a map with dict and send that: (dict "ctx" . "component" "api"). Inside the template, .ctx is the root context and .component is the component name.

Go's own template action also calls a named template, but you cannot feed its result into a pipeline: {{ template "bookstore.labels" . | nindent 4 }} does not work, because template is not a function but an action that writes its output directly. include does the same job and returns the result as a string, so it can be indented with nindent. Helm's Chart Template Guide recommends include for that reason, since it lets you control the output format. The Deployment now uses these helpers:

{{- $component := dict "ctx" . "component" "api" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "bookstore.fullname" . }}-api
  labels:
    {{- include "bookstore.labels" $component | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "bookstore.selectorLabels" $component | nindent 6 }}
  template:
    metadata:
      annotations:
        # A config change changes this hash, the pod template changes and the Deployment rolls.
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "bookstore.selectorLabels" $component | nindent 8 }}
    spec:
      serviceAccountName: {{ include "bookstore.fullname" . }}-api
      automountServiceAccountToken: false
      securityContext:
        {{- include "bookstore.podSecurityContext" . | nindent 8 }}
      containers:
        - name: api
          image: {{ include "bookstore.image" (dict "ctx" . "image" .Values.image) | quote }}
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          envFrom:
            - configMapRef:
                name: {{ include "bookstore.fullname" . }}-api
          securityContext:
            {{- include "bookstore.containerSecurityContext" . | nindent 12 }}
          ...
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}

The selector labels (name, instance, component) have no version; version and chart information live only in bookstore.labels. Putting a changing value into a selector means a field is immutable error on the first upgrade; we saw the same pitfall with Kustomize's commonLabels in the fourteenth post of the Kubernetes series. The securityContext is now defined in one place too; both components use the same restricted settings.

Flow Control and Scope: if, with, range and $

if runs for a non-empty value; with makes the same check and also changes . to that value inside the block. In the {{- with .Values.resources }} block, . is now the resources map; that is why toYaml . works, and when resources is empty the resources: key is not written at all. range loops over a list or a map and changes . to each element. In the ConfigMap we loop over the extraEnv map like this:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "bookstore.fullname" . }}-api
  labels:
    {{- include "bookstore.labels" (dict "ctx" . "component" "api") | nindent 4 }}
data:
  ASPNETCORE_ENVIRONMENT: {{ .Values.environment | quote }}
  Database__Provider: {{ .Values.database.provider | quote }}
  Bookstore__StoreName: {{ .Values.config.storeName | quote }}
  {{- range $key, $value := .Values.extraEnv }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
helm template bookstore ./bookstore -n bookstore-dev --show-only templates/configmap.yaml \
    --set extraEnv.Logging__LogLevel__Default=Debug --set extraEnv.Bookstore__Features__Wishlist=true
data:
  ASPNETCORE_ENVIRONMENT: "Development"
  Database__Provider: "InMemory"
  Bookstore__StoreName: "Bookstore"
  Bookstore__Features__Wishlist: "true"
  Logging__LogLevel__Default: "Debug"

Go templates loop over maps in key order; the output is the same on every run, which avoids pointless diffs. When the scope changes, the root context is reached through $. In the bookstore-web template we used with .Values.web; inside the block . is the web settings, and release and chart information needs $:

{{- if .Values.web.enabled }}
{{- $component := dict "ctx" . "component" "web" -}}
{{- with .Values.web }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "bookstore.fullname" $ }}-web
  labels:
    {{- include "bookstore.labels" $component | nindent 4 }}
spec:
  replicas: {{ .replicaCount }}
  selector:
    matchLabels:
      {{- include "bookstore.selectorLabels" $component | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "bookstore.selectorLabels" $component | nindent 8 }}
    spec:
      automountServiceAccountToken: false
      securityContext:
        {{- include "bookstore.podSecurityContext" $ | nindent 8 }}
      containers:
        - name: web
          image: {{ include "bookstore.image" (dict "ctx" $ "image" .image) | quote }}
          imagePullPolicy: {{ .image.pullPolicy }}
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: API_UPSTREAM
              value: {{ tpl .apiUpstream $ | quote }}
          securityContext:
          ...
{{- end }}
{{- end }}

When we forgot this and wrote include "bookstore.fullname" . inside the with block, Helm's error message points to the place but does not explain the cause:

# In web.yaml, inside the with block, . was used instead of $
  name: {{ include "bookstore.fullname" . }}-web
Error: bookstore/templates/web.yaml:7:11
  executing "bookstore/templates/web.yaml" at <include "bookstore.fullname" .>:
    error calling include:
bookstore/templates/_helpers.tpl:13:14
  executing "bookstore.fullname" at <.Values.fullnameOverride>:
    nil pointer evaluating interface {}.fullnameOverride

"nil pointer evaluating interface {}.fullnameOverride": the helper looked for .Values.fullnameOverride, but . was the web settings and had no Values. required, on the other hand, catches an empty value and turns it into a readable message:

helm template bookstore ./bookstore --set image.repository=
Error: execution error at (bookstore/templates/deployment.yaml:27:20): image.repository is required

Whitespace and Indentation Errors

{{- removes the whitespace and newlines on its left, -}} those on its right. This is what keeps {{- if }} and {{- end }} lines from leaving empty lines in the output. Indentation is entirely your responsibility: Helm renders text and knows nothing about the meaning of YAML. Let us see what happens when we write indent instead of nindent:

# indent instead of nindent
          resources:
            {{- toYaml . | indent 12 }}
Error: YAML parse error on bookstore/templates/deployment.yaml: error converting YAML to JSON: yaml: line 67: mapping values are not allowed in this context

Use --debug flag to render out invalid YAML
helm template bookstore ./bookstore --debug
            periodSeconds: 5
          resources:            limits:
              memory: 256Mi
            requests:
              cpu: 100m

Because indent does not add a newline, the first key stuck to the same line as resources:. The "line 67" in the error message is a line of the rendered file, not of the template; --debug also prints the invalid YAML, and it is easier to find the problem there. A practical rule: when a block should start on a new line, use the {{- ... | nindent N }} pattern, where N is the column the block should be indented to.

The ConfigMap and the Checksum Annotation

The new values section: non-secret settings under config, extraEnv for settings the chart does not model, and web for the second component.

# Non-secret settings; they reach the API as environment variables through a ConfigMap.
config:
  storeName: Bookstore

# Escape hatch for settings the chart does not model, e.g. Logging__LogLevel__Default: Debug
extraEnv: {}

# bookstore-web (Angular build served by nginx; /api is proxied to the API Service)
web:
  enabled: true
  replicaCount: 1
  image:
    repository: helmseries-bookstore-web
    tag: ""
    pullPolicy: IfNotPresent
  # Rendered with tpl, so it may use template expressions.
  apiUpstream: 'http://{{ include "bookstore.fullname" . }}-api.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }}'
  service:
    port: 8080
  resources:
    requests:
      cpu: 50m
      memory: 32Mi
    limits:
      memory: 64Mi

Now to the question from Guess First. As we saw in the seventh post of the Kubernetes series, when a ConfigMap read through envFrom changes, running pods do not see it; environment variables are read when the container starts. First we tried with a copy of the chart without the checksum annotation:

# A copy without the checksum/config annotation: first the switch to that version, then a config-only change
helm upgrade bookstore ./nochecksum -n bookstore-dev --set config.storeName="Bookstore (dev)" --wait
helm upgrade bookstore ./nochecksum -n bookstore-dev --set config.storeName="Bookstore (test)" --wait
kubectl -n bookstore-dev get configmap bookstore-api -o jsonpath='{.data.Bookstore__StoreName}'
kubectl -n bookstore-dev get pods -l app.kubernetes.io/component=api
Bookstore (test)
NAME                             READY   STATUS      RESTARTS   AGE
bookstore-api-58d5657cc5-s6pcf   1/1     Running     0          3s
bookstore-api-845594587f-t2hsv   0/1     Completed   0          58s
{"version":"1.1.0","environment":"Development","pod":"bookstore-api-58d5657cc5-s6pcf","database":"InMemory","storeName":"Bookstore (dev)"}

The ConfigMap says "Bookstore (test)", the pod did not restart, and the API still returns the old value. The Deployment's pod template did not change, so Kubernetes has no reason to roll out. The fix is the checksum/config annotation in the Deployment template: the ConfigMap template is rendered and its sha256sum is written into the annotation. When the config changes, the hash changes; when the hash changes, the pod template changes and the Deployment does a rolling update:

helm upgrade bookstore ./bookstore -n bookstore-dev --set config.storeName="Bookstore (test)" --wait
kubectl -n bookstore-dev get pods -l app.kubernetes.io/component=api
NAME                             READY   STATUS      RESTARTS   AGE
bookstore-api-58d5657cc5-s6pcf   0/1     Completed   0          9s
bookstore-api-dd6c449fb-q9f6x    1/1     Running     0          3s
{"version":"1.1.0","environment":"Development","pod":"bookstore-api-dd6c449fb-q9f6x","database":"InMemory","storeName":"Bookstore (test)"}

Kustomize solved this with a hash suffix added to the ConfigMap name (the fourteenth post of the Kubernetes series); in Helm this annotation gives the same result. There is one difference: Kustomize leaves a new ConfigMap behind on every change and a rollback goes back to the old ConfigMap, while in Helm the ConfigMap's name is fixed and helm rollback writes the old content again.

A Second Component: One Chart or an Umbrella?

bookstore-web serves the Angular 22 build with nginx and passes /api requests on to the API Service. nginx's upstream address sits in values as a template expression and is rendered with tpl:

helm template bookstore ./bookstore --show-only templates/web.yaml | grep -A1 API_UPSTREAM
            - name: API_UPSTREAM
              value: "http://bookstore-api.default.svc.cluster.local:8080"

(The namespace in this output is default because we did not pass -n to helm template.) nginx does not use Kubernetes' DNS search domains, so the address must be complete; thanks to tpl, the chart's user does not have to write it. After the upgrade there are two Deployments:

helm upgrade bookstore ./bookstore -n bookstore-dev --wait
kubectl -n bookstore-dev get deploy,pods
Release "bookstore" has been upgraded. Happy Helming!
...
NOTES:
Bookstore 1.1.0 (chart bookstore-0.2.0), release bookstore, revision 5

WARNING: namespace bookstore-dev has no pod-security.kubernetes.io/enforce label.
...
NAME                            READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/bookstore-api   1/1     1            1           2m24s
deployment.apps/bookstore-web   1/1     1            1           4s

NAME                                 READY   STATUS      RESTARTS   AGE
pod/bookstore-api-758bb6899-clvxf    1/1     Running     0          3s
pod/bookstore-api-cc5f75d5-mjx42     0/1     Completed   0          93s
pod/bookstore-web-76c9696d44-5klq5   1/1     Running     0          3s

We sent requests to the web from a curl pod inside the cluster:

# Through bookstore-web: nginx passes /api on to the API
curl -s http://bookstore-web:8080/api/info
curl -s http://bookstore-web:8080/ | head -5
{"version":"1.1.0","environment":"Development","pod":"bookstore-api-758bb6899-clvxf","database":"InMemory","storeName":"Bookstore"}
<!doctype html>
<html lang="en" data-beasties-container>
<head>
  <meta charset="utf-8">
  <title>Bookstore</title>

Keeping web and API in the same chart is a decision. One chart fits when both are versioned and deployed together: in Bookstore, web and API belong to the same team and the same release, and the chart version is one. An umbrella chart, where each component has its own chart and a parent chart pulls them in as dependencies, makes sense when the components belong to different teams, are released separately or must be installable on their own. The cost is that values sit under a separate key for each subchart and shared settings travel through global; we look at dependencies in the sixth post. For Bookstore we stay with one chart and leave the option to switch the web off with web.enabled.

lookup and Dry Runs

lookup reads resources from the cluster during the render. A practical use: warning in NOTES.txt when the namespace the release is installed into has no Pod Security Standards label.

Bookstore {{ .Chart.AppVersion }} (chart {{ .Chart.Name }}-{{ .Chart.Version }}), release {{ .Release.Name }}, revision {{ .Release.Revision }}
{{- $ns := lookup "v1" "Namespace" "" .Release.Namespace }}
{{- if and $ns (not (index ($ns.metadata.labels | default dict) "pod-security.kubernetes.io/enforce")) }}

WARNING: namespace {{ .Release.Namespace }} has no pod-security.kubernetes.io/enforce label.
{{- end }}
...

lookup returns an empty map in modes that do not connect to the cluster: helm template and --dry-run=client. So the template must allow for an empty result; the if and $ns ... check above is there for that. We compared the two modes:

helm upgrade bookstore ./bookstore -n bookstore-dev --dry-run=client
helm upgrade bookstore ./bookstore -n bookstore-dev --dry-run=server
NOTES:
Bookstore 1.1.0 (chart bookstore-0.2.0), release bookstore, revision 6

Try it:
...
NOTES:
Bookstore 1.1.0 (chart bookstore-0.2.0), release bookstore, revision 6

WARNING: namespace bookstore-dev has no pod-security.kubernetes.io/enforce label.

Try it:
...

The warning appeared only in server mode. The same difference applies to every template that makes decisions with lookup: the output you produce in CI with helm template may not be the output that gets installed in the cluster. That makes it risky to use lookup for things like reading an existing Secret and reusing its value; GitOps tools also mostly render with helm template and do not see the lookup result. After labelling the namespace and installing again, the warning went away and both components ran without trouble under the restricted profile:

kubectl label namespace bookstore-dev pod-security.kubernetes.io/enforce=restricted
helm upgrade bookstore ./bookstore -n bookstore-dev --set config.storeName="Bookstore (dev)" --wait
kubectl -n bookstore-dev get pods
namespace/bookstore-dev labeled
...
NOTES:
Bookstore 1.1.0 (chart bookstore-0.2.0), release bookstore, revision 6

Try it:
...
NAME                             READY   STATUS      RESTARTS   AGE
bookstore-api-758bb6899-clvxf    0/1     Completed   0          47s
bookstore-api-845594587f-t2hsv   1/1     Running     0          4s
bookstore-web-76c9696d44-5klq5   1/1     Running     0          47s
{"version":"1.1.0","environment":"Development","pod":"bookstore-api-845594587f-t2hsv","database":"InMemory","storeName":"Bookstore (dev)"}

In Production

Use the checksum annotation only for config that really goes into the pod; if you hash everything into the annotation, an unrelated change restarts every pod. The same pattern works for Secrets, but if you render a Secret's content in a template you are already keeping the secret in values; we look at that in the fourth post. Producing resources in templates based on lookup gives different results with tools that render for GitOps; where possible, let the decision come from values. tpl is powerful but turns values into templates too: anyone who writes a values file can run code inside the chart, so use tpl only for documented fields.

Common Pitfalls

Exposing every Kubernetes field through values and writing YAML with YAML: the podSecurityContext: {}, affinity: {}, tolerations: [] list from helm create is how it starts, and in the end the chart is harder to read than the manifest. Expose the settings that really change and leave a narrow escape hatch such as extraEnv for the rest. Calling with template and then not being able to use nindent. Forgetting that . changes inside with or range. Missing that default treats 0 and false as empty too. Mixing up indent and nindent. Believing config changes reach the pods by themselves. Assuming lookup reads the cluster in helm template too.

A Small Experiment

★☆☆ Render with web.enabled=false and check that NOTES.txt shows the port-forward command for the API.

★★☆ Add an optional commonLabels map from values to the bookstore.labels helper (with with and toYaml). Confirm in the helm template output that these labels do not get into the selectors.

★★★ Try turning the chart into an umbrella: two subcharts called charts/api and charts/web and a parent chart that pulls them together. Compare how the values are split and how the API Service name reaches the web, then write down, with your own reasons, why we went back to one chart.

The templates are now readable and carry two components. In the next post we design the values: values files per environment, the merge order, validation with values.schema.json, and keeping secrets out of values.