Lesson 03 Templating Fundamentals
Theme:

Templating Fundamentals

Lesson 03 — Theory Reading (~1500 words)

1. Why Templates?

Kubernetes manifests are static YAML. If you need the same Deployment for staging and production with different replica counts, image tags, or resource limits, you end up copy-pasting files and editing them by hand. Helm solves this by treating manifests as templates — YAML files with embedded Go template directives that are rendered at install or upgrade time using a set of values.

When you run helm install or helm template, Helm feeds your values.yaml (plus any --set overrides) into the template engine, which evaluates every action between {{ }} delimiters and produces plain YAML that gets sent to the Kubernetes API server.

2. Go Template Syntax

Helm uses the Go standard library text/template package (with Sprig extensions). The core concept is simple: everything outside {{ }} is emitted verbatim; everything inside is an action that is evaluated.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-config
data:
  greeting: "Hello from {{ .Values.appName }}"

The dot (.) is the current scope. At the top level of a Helm template, . is a map containing all built-in objects. As you enter control structures like with or range, the dot is re-bound to a narrower value.

Pipelines

Actions can be chained using the pipe operator |, which passes the output of the left side as the last argument to the function on the right — identical in spirit to Unix shell pipes.

# Pipeline: value -> default -> quote
environment: {{ .Values.env | default "production" | quote }}

Pipelines are the idiomatic way to compose transformations in Helm templates. They keep templates readable and avoid deeply nested function calls.

3. Built-in Objects

Every Helm template receives five top-level objects. Understanding what each one provides is essential for writing effective templates.

ObjectDescription
.ValuesThe merged result of the chart's values.yaml and any user overrides (--set, -f). This is where virtually all configurable data lives.
.ReleaseMetadata about the release: .Release.Name, .Release.Namespace, .Release.Revision, .Release.IsInstall, .Release.IsUpgrade, .Release.Service.
.ChartThe parsed Chart.yaml: .Chart.Name, .Chart.Version, .Chart.AppVersion, .Chart.Description.
.CapabilitiesInformation about the target cluster: .Capabilities.KubeVersion and .Capabilities.APIVersions. Useful for generating version-appropriate manifests.
.TemplateThe current template file: .Template.Name (e.g., mychart/templates/deployment.yaml) and .Template.BasePath.

Release Object Deep Dive

.Release.IsInstall is true on first install, false on upgrade. .Release.IsUpgrade is the inverse. These booleans are useful for one-time setup resources like database migrations that should only run during the initial install.

4. Control Structures

Helm supports three control structures inherited from Go templates. These let you conditionally include resources, change scope, and iterate over collections.

if / else

Conditionally render a block. The condition is "falsy" for false, 0, nil, an empty string, and empty collections.

{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ .Release.Name }}-sa
  {{- if .Values.serviceAccount.annotations }}
  annotations:
    {{- toYaml .Values.serviceAccount.annotations | nindent 4 }}
  {{- end }}
{{- end }}

with (scope change)

The with action re-scopes the dot to the given value. If the value is empty/falsy, the block is skipped entirely — making with both a scope-changer and a conditional.

{{- with .Values.nodeSelector }}
nodeSelector:
  {{- toYaml . | nindent 2 }}
{{- end }}

Inside the with block, . refers to .Values.nodeSelector, not the top-level scope. If you need access to the root scope inside with, use $ (which always refers to the root context).

{{- with .Values.nodeSelector }}
  # "." is nodeSelector, but "$" is the root
  release: {{ $.Release.Name }}
  {{- toYaml . | nindent 2 }}
{{- end }}

range (iteration)

Iterate over lists and maps. Inside range, the dot is re-scoped to each element.

# Iterating a list of maps
env:
{{- range .Values.env }}
  - name: {{ .name }}
    value: {{ .value | quote }}
{{- end }}

# Iterating a map (key-value pairs)
annotations:
{{- range $key, $val := .Values.podAnnotations }}
  {{ $key }}: {{ $val | quote }}
{{- end }}

Practical Tip

When iterating over ports, use a list of maps in values.yaml:

ports:
  - name: http
    port: 80
    protocol: TCP
  - name: metrics
    port: 9090
    protocol: TCP

Then in your template: {{- range .Values.ports }} gives you access to .name, .port, and .protocol on each element.

5. Template Functions

Helm includes all functions from the Go text/template package plus the Sprig function library (over 70 functions). Here are the ones you will use most often:

FunctionWhat It DoesExample
defaultReturns a default if the value is empty{{ .Values.port | default 8080 }}
quoteWraps value in double quotes{{ .Values.env | quote }} → "production"
toYamlSerializes a value to a YAML string{{ toYaml .Values.resources | nindent 6 }}
nindentPrepends a newline then indents N spacesEssential partner for toYaml
indentIndents N spaces (no leading newline){{ toYaml . | indent 4 }}
upper / lowerCase conversion{{ .Values.logLevel | upper }}
trimStrip whitespace from both ends{{ .Values.name | trim }}
requiredFail render if value is empty{{ required "tag needed" .Values.image.tag }}
printfFormatted string{{ printf "%s-%s" .Release.Name .Chart.Name }}
ternaryInline conditional{{ ternary "yes" "no" .Values.debug }}

toYaml + nindent: The Essential Combo

When you want to dump a YAML subtree from values directly into a template, the toYaml + nindent pattern is the standard approach:

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

The nindent 2 ensures the serialized YAML is properly indented under the resources: key. Using indent instead would not insert the necessary leading newline, causing the first line to appear on the same line as resources:.

6. Whitespace Control

One of the trickiest aspects of Helm templating is whitespace management. The template engine preserves all literal text (including newlines and spaces) that exists outside of {{ }} actions. This means conditional blocks and loops often introduce unwanted blank lines.

Helm provides trim markers:

# WITHOUT trim markers - produces blank lines
metadata:
  labels:
    {{ if .Values.team }}
    team: {{ .Values.team }}
    {{ end }}
    app: myapp

# WITH trim markers - clean output
metadata:
  labels:
    {{- if .Values.team }}
    team: {{ .Values.team }}
    {{- end }}
    app: myapp

Common Pitfall

Using -}} on a line that also has literal text to its right will eat that text's leading whitespace, potentially breaking YAML indentation. Only use -}} when there is nothing but whitespace/newline after the closing braces. Always validate with helm template .

7. Putting It All Together

Here is a realistic values.yaml paired with a template that exercises every concept covered in this lesson:

values.yaml

replicaCount: 3
image:
  repository: nginx
  tag: "1.25"
env:
  - name: LOG_LEVEL
    value: info
  - name: PORT
    value: "8080"
ports:
  - name: http
    port: 80
  - name: metrics
    port: 9090
resources:
  limits:
    cpu: 500m
    memory: 256Mi
ingress:
  enabled: true

templates/deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-app
  labels:
    app.kubernetes.io/name: {{ .Chart.Name }}
    app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
    app.kubernetes.io/instance: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicaCount | default 1 }}
  selector:
    matchLabels:
      app.kubernetes.io/instance: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/instance: {{ .Release.Name }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
          {{- range .Values.ports }}
            - name: {{ .name }}
              containerPort: {{ .port }}
              protocol: {{ .protocol | default "TCP" }}
          {{- end }}
          {{- if .Values.env }}
          env:
          {{- range .Values.env }}
            - name: {{ .name }}
              value: {{ .value | quote }}
          {{- end }}
          {{- end }}
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}

This single template demonstrates value interpolation (.Values.*, .Release.*, .Chart.*), pipelines (default, quote), range (iterating ports and env vars), with (conditional resources block), toYaml + nindent (dumping structured YAML), and whitespace trim markers ({{-).

Summary

Helm templating transforms static Kubernetes YAML into reusable, parameterized charts. The core building blocks are:

  1. Go template actions ({{ }}) for interpolation and logic.
  2. Built-in objects (.Values, .Release, .Chart, .Capabilities, .Template) providing all necessary context.
  3. Control structures (if, with, range) for conditional rendering and iteration.
  4. Functions and pipelines for transforming values inline.
  5. Whitespace trim markers ({{- / -}}) for clean YAML output.

With these fundamentals, you can template any Kubernetes resource. In the next lesson we will explore namespaces and labels in Helm, but first, work through the Lab Guide to practice these concepts hands-on.