CoursesHelmFunctions, pipelines & named templates

Functions, pipelines & named templates

DRY templates with helpers.

Intermediate12 min · lesson 6 of 12

Every programming language ships with a standard library, the batteries you did not have to build yourself. Helm's template engine works the same way. It hands you a shelf of ready-made functions for strings, lists, math, and dates, and it lets you write your own helpers on top. A helper is a recipe card taped to the kitchen wall: write the steps once, and every cook makes the dish the same way. Change the card, and every plate changes with it. That is the job of this lesson. Write a block of YAML (the indentation-based text format Kubernetes configuration files are written in) once, give it a name, and call it from twenty manifests (the YAML files that describe your Kubernetes objects), so a fix lands in one place instead of twenty. Engineers call this staying DRY (Don't Repeat Yourself).

Pipelines: one value down a conveyor belt

A pipeline in a Helm template reads like a pipe in a shell. A value goes in on the left, passes through one function after another, and each stage reshapes it before handing it on. A factory line works the same way: the part enters raw at one end and comes out finished at the other. The pipe character | does the joining, and here is the rule that trips people up. The pipe feeds the previous result in as the LAST argument of the next function, not the first. So .Values.name | default "app" means default "app" .Values.name, which reads as "use .Values.name, but fall back to app if it is empty."

You can write the same logic two ways. Nested, which you read right to left, or piped, which you read left to right. They produce identical output. Pipelines win because they run in the same order your eye does.

templates/config.yaml
# nested, read right to left
name: {{ quote (default "app" .Values.name) }}
# the same result as a pipeline, read left to right
name: {{ .Values.name | default "app" | quote }}
# cut to the 63-char limit, then shave any trailing dash
name: {{ .Values.appName | lower | trunc 63 | trimSuffix "-" }}

A handful of functions do most of the daily work. default supplies a fallback when a value is empty, so a missing setting does not render a blank line. quote wraps a value in double quotes, which stops a string like yes or 1.20 from being read by YAML as a boolean or a number. trunc 63 and trimSuffix "-" keep you inside a hard Kubernetes limit: a name that has to be a single DNS label (a Service name is one) can be at most 63 characters, and a label value is capped at 63 too. That ceiling comes from DNS (the Domain Name System, whose naming scheme Kubernetes reuses), and such names cannot end in a dash, so you cut to 63 then shave any trailing dash. printf builds a composite string from parts. And the pair toYaml plus nindent takes a whole block of values and drops it into a manifest with the indentation lined up: toYaml turns a structure back into YAML text, and nindent N adds a leading newline and indents every line it emits by N spaces.

A value flowing through a pipeline
1.Values.appName
"Payments-API"
2lower
"payments-api"
3trunc 63
cut to 63 chars
4trimSuffix "-"
drop a trailing dash
5quote
"payments-api"
Each function takes the result of the previous stage as its final argument, reshapes it, and passes it on. Read left to right, the way the data moves.

The two functions Helm refuses to give you

Sprig, the function library Helm bundles, includes two helpers that read the operating system's environment variables: env and expandenv. Helm strips both out of its engine before it renders a single template. The reason is defensive. A chart is often rendered on a shared build runner (a machine in your CI, or continuous integration, pipeline) whose environment holds cloud keys and tokens. It works like a contractor you buzz in to fix one sink: they get into that room, not the ring of master keys to every office. If a chart could call env "AWS_SECRET_ACCESS_KEY", a careless or malicious chart could copy that secret straight into a ConfigMap (a Kubernetes object that stores plain-text configuration) and ship it to the cluster in the clear. Try it, and Helm refuses before it renders anything.

templates/leak.yaml
# an attacker's idea, blocked by Helm
secret: {{ env "AWS_SECRET_ACCESS_KEY" | quote }}
terminal
helm template web ./mychart --show-only templates/leak.yaml
output
Error: parse error at (mychart/templates/leak.yaml:2): function "env" not defined

A defender reads that error as a feature. Your render step is sealed off from the host it runs on, so a chart pulled from a public registry cannot skim the runner's environment. When you genuinely need a value from outside, you pass it in on purpose with --set or a values file, where it is visible in your pipeline logs and your Git history, not smuggled out through a template.

Named templates: define once, include everywhere

A named template is a fragment of YAML you declare with define and pull in with include. It is the recipe card again, now filed in a shared drawer. By convention these live in files under templates/ whose names start with an underscore, like _helpers.tpl. The underscore is a signal to Helm: never render this file into a Kubernetes object on its own. It only holds helpers for other files to call. Template names are global across the whole chart and every subchart it pulls in (a subchart is a chart bundled inside yours as a dependency), so you namespace each one with a dotted prefix, mychart.fullname rather than a bare fullname, to stop a subchart from clobbering your helper with one of its own.

helm create scaffolds this pattern for you. You get a fullname helper that stitches the release name (the name you give one installed instance of a chart) and the chart name together, cuts the result to 63 characters, and trims a trailing dash. The real scaffold adds a guard or two so the name never doubles up, but this is the shape. You also get a shared labels block and a selector block. Define the standard labels once and every Deployment, Service, and ServiceAccount carries the same set, so a query like kubectl get all -l app.kubernetes.io/instance=web (kubectl is the command-line tool for talking to a cluster) finds every object in the release. Notice the dash-trimmed delimiters, {{- and -}}. They strip the whitespace around the action so the emitted YAML does not sprout blank lines and stray indentation.

templates/_helpers.tpl
{{/* The chart name, overridable from values. */}}
{{- define "mychart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/* A release-scoped name that is always a legal Kubernetes name. */}}
{{- define "mychart.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/* The label set every object in the release shares. */}}
{{- define "mychart.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
selector:
app.kubernetes.io/name: {{ include "mychart.name" . }}
ports:
- port: {{ .Values.service.port | default 80 }}
terminal
helm template web ./mychart --show-only templates/service.yaml
output
---
# Source: mychart/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: web-mychart
labels:
helm.sh/chart: mychart-0.1.0
app.kubernetes.io/name: mychart
app.kubernetes.io/instance: web
app.kubernetes.io/managed-by: Helm
spec:
selector:
app.kubernetes.io/name: mychart
ports:
- port: 80

Scope, and why include beats the template action

Two things confuse everyone the first time: how to control the indentation of a helper's output, and what the dot means inside the helper. Take them in order. Always call a helper with include, never with the older template action. Here is the difference in one image. include is a photocopier: it runs the helper, hands you the printed page as a string, and lets you trim and align it before you paste it in. template is a printer wired straight to the output tray: it dumps the text where it stands and hands you nothing back. Because include returns a string, you can pipe it into nindent and set the indentation exactly. Because template returns nothing, it cannot sit in a pipeline, and you lose all control of the whitespace.

The last argument you hand a helper is the scope it sees as dot. Think of it as the folder you walk in holding: the helper can only read what is inside it. Pass a plain . and the helper gets the current context. Pass $ and it gets the root context, the top-level scope that always holds .Values, .Release, and .Chart, no matter how deep you are. This matters inside a range loop, because there dot is rebound to the current loop item, and the helper can no longer reach .Release. The fix is to bundle both the root and the item into a small dictionary with dict, pass that, and read the pieces back by key.

templates/services.yaml
{{- range .Values.services }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .name }}
labels:
{{- include "mychart.serviceLabels" (dict "top" $ "svc" .) | nindent 4 }}
{{- end }}
{{/* the helper reads root ($) and item back by key */}}
{{- define "mychart.serviceLabels" -}}
app.kubernetes.io/instance: {{ .top.Release.Name }}
service: {{ .svc.name }}
{{- end -}}

One trap worth burning in: {{ template "mychart.labels" . | nindent 4 }} is a parse error, not a formatting quirk. template is a bare action and cannot appear in a pipeline, so the | nindent has nothing to attach to. That is the whole reason include exists: same lookup, but it returns a string you can pipe. When a helper's output shows up jammed against the previous key or shoved to column zero, check that you used include, and that nindent (which supplies its own leading newline) sits right after a mapping key with no extra newline stacked on top.

Check what actually rendered

Rendering without an error does not mean the cluster will accept the result. helm template and helm lint render text and check that it parses as YAML. They do not enforce the rules of the API server (the control-plane component that accepts, validates, and stores every object), like the 63-character cap on a name, because that check lives in the cluster, not in Helm. So a helper that keeps its trunc 63 | trimSuffix "-" protects you even when someone feeds it a comically long override. Prove it by piping the render into a server-side dry run, which asks the real API server to validate the object without creating it.

terminal
helm template web ./mychart \
--set nameOverride=payments-api-with-an-unusually-descriptive-and-far-too-long-name \
--show-only templates/service.yaml | kubectl apply --dry-run=server -f -
output
service/web-payments-api-with-an-unusually-descriptive-and-far-too-long created (server dry run)
Lint is not the API server
helm lint and helm template never check name length. Drop the trunc 63 | trimSuffix "-" from a fullname helper and everything renders green, then a real release fails at apply time with The Service "web-..." is invalid: metadata.name: Invalid value: "web-...": must be no more than 63 characters. Put a server-side dry run in CI so a broken helper is caught before it reaches a live namespace, not after.
Quick check
01Your services.yaml loops with {{- range .Values.services }} and calls a helper that needs .Release.Name, but that field renders blank on every service. What do you change?
Incorrect — The underscore only tells Helm never to render that file as an object by itself; it places no limit on what a helper may read.
Incorrect — Both actions look up the same definition and see the same dot; template only differs by returning nothing you can pipe.
Correct — The loop rebinds dot to the current item, so you hand the helper one dictionary carrying both the root and that item.
Incorrect — Helm removes env and expandenv from its engine, so that line fails to parse before a single object renders.
02In {{ .Values.appName | lower | trunc 63 | trimSuffix "-" }}, trunc expects a length and the text to cut. Where does the lowercased name land in that call?
Correct — Every pipe appends the previous result to the end of the argument list, which is why the piped and nested forms match.
Incorrect — That ordering would break default, which needs the fallback first and the value it is testing last.
Incorrect — There is no implicit variable here; the value travels by position alone, which is what makes the pipe predictable.
Incorrect — Each stage works on what the one before it produced, so the truncation acts on the lowercased text, not the raw value.
03You write {{ template "mychart.labels" . | nindent 4 }} in service.yaml and helm template dies with a parse error before printing a single line. What fixes it?
Incorrect — Dash-trimmed delimiters tidy blank lines and stray indentation in the output; they cannot make a bare action pipeable.
Incorrect — Template names are global across the chart no matter which file defines them, and the underscore only stops solo rendering.
Incorrect — Neither function can rescue this line; they differ only in that leading newline, and the pipe itself is what fails.
Correct — That is the reason both forms exist: one dumps text straight into the output, the other returns it for you to reshape.

When a helper misbehaves, render it in isolation before you guess. helm template web ./mychart --show-only templates/service.yaml prints exactly one file, so you see the real whitespace the engine produced instead of scrolling the whole release. Nine times out of ten the bug is a missing dash in a delimiter or a template where you meant include, and it is sitting right there in the output waiting for you.

Try this

Run helm template web ./mychart --show-only templates/leak.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: lint is not the API server. 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