Helm & Kubernetes Packaging

Templating Deep Dive

18 min Lesson 4 of 28

Templating Deep Dive

Helm's superpower is its template engine. Every file inside templates/ is processed by Go's text/template package, augmented with the Sprig function library and Helm-specific builtins. Understanding this engine at a deep level separates engineers who write brittle charts from those who write reusable, production-grade ones. This lesson covers the full execution model: how values flow in, how functions and pipelines transform them, and how conditionals and loops let a single template adapt to every environment.

The Template Execution Context

Every Helm template executes within a root object called . (dot). This object is a merged map of several namespaces that Helm populates before rendering begins:

  • .Values — the merged result of values.yaml plus every --values file plus every --set flag, in priority order (later wins).
  • .Chart — metadata from Chart.yaml: .Chart.Name, .Chart.Version, .Chart.AppVersion.
  • .Release — runtime release state: .Release.Name, .Release.Namespace, .Release.IsInstall, .Release.IsUpgrade.
  • .Capabilities — cluster introspection: .Capabilities.KubeVersion.Minor, .Capabilities.APIVersions.Has "networking.k8s.io/v1".
  • .Files — access to non-template files bundled in the chart (config files, certs).
Helm template execution context values.yaml + --values + --set Chart.yaml name, version, appVersion Release Context name, namespace, revision Capabilities KubeVersion, API groups . root context .Values .Chart .Release Go Template Engine + Sprig functions, pipelines conditionals, loops YAML manifests
Helm merges all inputs into a single root context (dot) and passes it through the Go template engine to produce rendered Kubernetes manifests.
Scope shift warning: When you enter a range loop or a with block, . is rebound to the current element or value. To reach the outer release name inside a loop, use $.Release.Name — the $ prefix always refers to the top-level root object, regardless of scope.

Values — the Public API of a Chart

values.yaml is not just a defaults file — it is the documented interface to your chart. Every key in it is a knob that operators can override. Design it the way you design a function signature: clear names, sensible defaults, grouped by concern.

# values.yaml — well-structured example replicaCount: 2 image: repository: myregistry.io/myapp tag: "" # intentionally blank — set at deploy time via --set image.tag=1.4.2 pullPolicy: IfNotPresent service: type: ClusterIP port: 8080 ingress: enabled: false # feature flag — operators opt-in className: nginx host: "" tls: [] resources: requests: cpu: "100m" memory: "128Mi" limits: cpu: "500m" memory: "512Mi" autoscaling: enabled: false minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70 podAnnotations: {} # map — allows arbitrary key/value injection

Referencing values in a template is straightforward: {{ .Values.replicaCount }}. For nested keys: {{ .Values.image.repository }}. Helm renders the entire template before applying it, so a typo in a value reference produces an error at render time — not silently at runtime.

Functions and Pipelines

Helm ships with over 70 functions from Sprig plus its own additions. The key insight is that they compose via pipelines — the Unix pipe model applied to template data. The output of one function becomes the last argument of the next.

# In a template file — common function patterns # quote: wraps the value in double-quotes (required for env var string values) env: - name: APP_ENV value: {{ .Values.appEnv | quote }} # default: supply a fallback when a value is empty image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }} # upper / lower / title — string transforms - name: LOG_LEVEL value: {{ .Values.logLevel | upper | quote }} # toYaml + nindent — the most important pipeline in Helm # Dump a complex value (map or list) as indented YAML in-place resources: {{ toYaml .Values.resources | nindent 10 }} # tpl — evaluate a string value as a template (useful for constructing URLs) annotations: "external-dns.alpha.kubernetes.io/hostname": {{ tpl .Values.ingress.host . | quote }} # include + nindent (for named templates — covered in lesson 5) metadata: labels: {{ include "myapp.labels" . | nindent 4 }} # sha256sum — inject a checksum annotation so pods roll on configmap changes annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
Production pattern — forced pod rollout on ConfigMap change: Kubernetes does not restart pods when a ConfigMap changes. The sha256sum annotation trick encodes the rendered ConfigMap content into the Deployment's pod spec annotation. Any ConfigMap change changes the hash, which changes the pod spec, which Kubernetes treats as a rolling update. This is a near-universal practice in mature Helm charts.
Common pitfall — forgetting nindent vs indent: indent N adds N spaces to every line of its input but does NOT add a leading newline. nindent N adds a newline first, then indents. In YAML, missing or extra whitespace silently corrupts structure. Always use nindent after a key that expects a block, and use toYaml | nindent X rather than hand-formatting nested values.

Conditionals

Helm conditionals use Go template's if / else if / else / end syntax. The condition is falsy for: false, 0, empty string, nil, empty list, and empty map. Everything else is truthy.

# templates/ingress.yaml — conditional rendering of the entire resource {{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "myapp.fullname" . }} {{- with .Values.ingress.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- if .Values.ingress.className }} ingressClassName: {{ .Values.ingress.className }} {{- end }} rules: - host: {{ .Values.ingress.host | quote }} http: paths: - path: / pathType: Prefix backend: service: name: {{ include "myapp.fullname" . }} port: number: {{ .Values.service.port }} {{- if .Values.ingress.tls }} tls: {{- toYaml .Values.ingress.tls | nindent 4 }} {{- end }} {{- end }}

The - dash inside delimiters ({{- and -}}) trims whitespace and newlines on the respective side. This is critical: without it, conditional blocks leave blank lines in the rendered YAML that can trip up strict parsers or produce confusing diffs in your GitOps tooling.

The with block is a focused form of if — it both guards against empty values and rebinds . to the value, removing repetition:

# templates/deployment.yaml — with block for optional annotations metadata: name: {{ include "myapp.fullname" . }} {{- with .Values.podAnnotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} # Equivalent but more verbose: # {{- if .Values.podAnnotations }} # annotations: # {{- toYaml .Values.podAnnotations | nindent 4 }} # {{- end }} # Capabilities-based conditional — emit HPA only if autoscaling API is available {{- if and .Values.autoscaling.enabled (.Capabilities.APIVersions.Has "autoscaling/v2") }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler ... {{- end }}

Loops with range

The range action iterates over lists and maps. It is the mechanism behind rendering environment variables, volume mounts, init containers, and any other list-typed field in Kubernetes manifests.

# templates/deployment.yaml — ranging over a list and a map # Iterate a list of extra env vars (each item is a map with name/value) # values.yaml: extraEnv: [{name: "FEATURE_X", value: "on"}, {name: "REGION", value: "us-east-1"}] env: - name: PORT value: {{ .Values.service.port | quote }} {{- range .Values.extraEnv }} - name: {{ .name | quote }} value: {{ .value | quote }} {{- end }} # Iterate a map — range gives you ($key, $val) or just $val # values.yaml: labels: {team: platform, env: prod} {{- range $key, $val := .Values.extraLabels }} {{ $key }}: {{ $val | quote }} {{- end }} # Loop with index — useful when order matters (e.g., init containers) {{- range $i, $container := .Values.initContainers }} - name: init-{{ $i }} image: {{ $container.image | quote }} command: {{ toYaml $container.command | nindent 6 }} {{- end }}
Helm range loop rendering list to YAML extraEnv (list in values.yaml) name: FEATURE_X value: "on" name: REGION value: "us-east-1" name: TIMEOUT value: "30s" range {{- range . }} dot = each item renders block 3x Rendered YAML (env block) - name: "FEATURE_X" value: "on" - name: "REGION" value: "us-east-1" - name: "TIMEOUT" value: "30s"
The range action iterates over a list in values.yaml and renders the template block once per item, building the final YAML list.

Putting It Together — a Production Deployment Template

The following template is representative of what you find in a mature, production-grade chart. It combines all the concepts from this lesson: value references, pipelines with toYaml | nindent, feature-flagged blocks, capability checks, and a range loop for environment variables.

{{- /* templates/deployment.yaml */ -}} apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "myapp.fullname" . }} namespace: {{ .Release.Namespace }} labels: {{- include "myapp.labels" . | nindent 4 }} {{- with .Values.deploymentAnnotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} {{- end }} selector: matchLabels: {{- include "myapp.selectorLabels" . | nindent 6 }} template: metadata: annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} labels: {{- include "myapp.selectorLabels" . | nindent 8 }} spec: containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: {{ .Values.service.port }} protocol: TCP env: - name: PORT value: {{ .Values.service.port | quote }} {{- range .Values.extraEnv }} - name: {{ .name | quote }} value: {{ .value | quote }} {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} {{- if .Values.livenessProbe }} livenessProbe: {{- toYaml .Values.livenessProbe | nindent 12 }} {{- end }}
Debugging templates: Use helm template myapp ./mychart --values prod.yaml to render templates locally without touching a cluster. Pipe through | grep -A5 "kind: Deployment" to focus on one resource. For deeper inspection, helm install myapp ./mychart --dry-run --debug hits the cluster API for validation but does not apply anything — it also prints the computed values so you can verify your override chain is correct.

Mastery of Helm's template engine — values flow, function pipelines, whitespace-trimming dashes, with/range/if, and the root vs. scoped dot — is what separates a chart that works in one environment from one that reliably powers a multi-tenant, multi-environment platform. Lesson 5 extends this with named templates and helpers to eliminate duplication across files.

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!