DEV Community

secopslog for SecOpsLog

Posted on • Originally published at secopslog.com

Helm template functions, pipelines & named templates

DRY templates with helpers.

If the templating engine is the language, functions and named templates are its standard library plus the reusable helpers you write yourself. Think of a pipeline the way a shell pipes commands: a value flows left to right, and each function reshapes it before handing it on. Named templates are the functions you author — define a block of YAML once, give it a name, then call it from every manifest so a change lands in one place instead of twenty. The goal is staying DRY: composing built-in functions into pipelines, and factoring shared fragments into reusable helpers.

Functions and pipelines

Helm ships the Go template built-ins plus the entire Sprig library — string, list, math, encoding and date helpers. You call them two ways. Nested, reading right to left: quote (upper .Values.name). Or piped, reading left to right, where the pipe feeds the previous result in as the last argument of the next function. Pipelines are almost always clearer. The workhorses are default to supply a fallback, quote to wrap a value safely, trunc and trimSuffix to respect Kubernetes' 63-character name limit, and the conversion pair toYaml plus nindent for embedding a whole values block with correct indentation. Reach for printf when you need to build a composite string. Note that Helm deliberately disables a few Sprig functions such as env and expandenv, so a chart cannot read the machine it happens to render on.

# nested calls read right-to-left
name: {{ quote (default "app" .Values.name) }}

# the same thing as a pipeline, reading left-to-right
name: {{ .Values.name | default "app" | quote }}

# respect the 63-char name limit, then embed a whole block
label: {{ .Values.appName | lower | trunc 63 | trimSuffix "-" }}
config:
  {{- .Values.settings | toYaml | nindent 2 }}
Enter fullscreen mode Exit fullscreen mode

Named templates with define and include

A named template is a fragment declared with define and rendered with include. By convention they live in templates/_helpers.tpl — files whose names start with an underscore are never rendered into Kubernetes objects themselves, they only supply helpers. Template names are global across a chart and all its subcharts, so the name is a dotted string namespaced to your chart, like myapp.labels, to keep subcharts from colliding. helm create scaffolds this pattern for you: a fullname helper that stitches the release name and chart name together, truncates to 63 characters and trims a trailing dash, plus shared label and selector blocks. Define the standard labels once and every Deployment, Service and ServiceAccount stays consistent for free. The dash-trimmed delimiters,{{ and }}, matter inside helpers because they strip the surrounding whitespace so the emitted YAML lines up.

templates/_helpers.tpl

{{/* A release-scoped name, safe for Kubernetes */}}
{{- define "myapp.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{/* Standard labels reused by every object */}}
{{- define "myapp.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}
Enter fullscreen mode Exit fullscreen mode

Scope, and why include beats template

Two things trip everyone up: how to indent the output, and what dot means inside the helper. Always call helpers with include, not the template action. include is a Helm-specific function that returns the rendered text as a string, so you can pipe it into nindent. template prints straight to output and cannot be piped, so you lose all control of indentation. The final argument you pass is the scope the helper sees as dot. Passing a plain dot hands over the current context; passing a dollar sign hands over the root context — the top-level scope that always holds .Values, .Release and .Chart. Inside a range, dot is the loop item, so to give a helper both the item and the root you package them into a dict and read them back by key.

# in a template that loops, bundle root ($) and item into one dict
{{- range .Values.services }}
  {{- include "myapp.serviceLabels" (dict "top" $ "svc" .) | nindent 4 }}
{{- end }}

# the helper reads the values back by key
{{- define "myapp.serviceLabels" -}}
app.kubernetes.io/instance: {{ .top.Release.Name }}
service: {{ .svc.name }}
{{- end -}}
Enter fullscreen mode Exit fullscreen mode

⚠️ include is pipeable; template is not: Writing {{ template "myapp.labels" . | nindent 4 }} is a parse error — template is a bare action, not a function, so it cannot appear in a pipeline. Helm added include precisely to fix this: it does the same lookup but returns a string you can pipe into nindent, indent or toYaml. Prefer nindent N over a leading newline plus indent N, because nindent prepends the newline for you — which is exactly why you must never stack your own leading newline on top of it, or you get a blank line and shifted indentation. nindent sets the block's indentation absolutely and re-indents every line it emits, so the normal, correct pattern is to put the include right after a mapping key (or alone on a dash-trimmed line) and let it own the whitespace. What you must not do is append anything after the include on the same line: its output ends without a trailing newline, so trailing text lands mis-indented — YAML that can lint clean locally yet fail once the manifest reaches the cluster.


This lesson is part of the free *Helm** course at SecOpsLog — hands-on, command-first DevSecOps tutorials. Read the original with the full interactive version →*

Top comments (0)