Raising templating abstraction to production level: named templates in _helpers.tpl, the consistent chart.fullname pattern, conditional rendering, whitespace management, loops & iteration, and the best practices that set amateur charts apart from enterprise charts.

After episode 9, where we dissected template functions and helper functions — from printf for formatting strings, dict and merge for manipulating maps, to include, required, fail, and toYaml — in this episode we raise the abstraction one level higher: named templates, or templates that are given a name and can be called from other templates.
Why does this topic become a turning point in your journey? Imagine a chart that only contains one Deployment: inline templating still feels comfortable. But once the chart grows into a production application with Deployment, Service, Ingress, ServiceAccount, and ConfigMap — each repeatedly using the same label, name, and convention blocks — copied syntax starts scattering across dozens of files. Every time a convention changes (for example, the app.kubernetes.io/version label format gains a new rule), you have to remember to change it everywhere. That's a recipe for drift and inconsistency. Named templates are Helm's answer to the same problem that functions solve in programming languages: write once, call many times, and changes live in one place. This is the foundation you'll need when building charts used across teams — and it's something we'll keep using in all the following episodes, including dependencies, hooks, and testing.
Helm renders every file in the templates/ directory independently. Two consequences follow from this design. First, identical logic — for example, "generate a resource name following the convention" — can't be automatically shared between files. Second, one file's rendered output can't easily be "inserted" into another file; service.yaml can't call a helper defined in deployment.yaml because Helm doesn't care which file defines what.
Named templates solve both. All templates named via {{- define "name" -}} blocks live in one global namespace within the chart (and its parent chart, which we'll cover in episode 11). Once a name is defined, any file in templates/ can call it — that's why the dedicated _helpers.tpl file exists: the _ prefix tells Helm the file must not be rendered as a manifest, so its contents are only definitions usable by other templates. Think of _helpers.tpl as a module's function library, and the other templates/ files as its callers.
Defining a named template starts with the define action:
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}There are three details that often confuse beginners. First, the {{- and -}} on the define and end sides are whitespace trimming — without them, the blank lines produced by define get rendered too and break the YAML output. Second, template names must be globally unique within the chart. Helm doesn't forbid duplicates — it silently uses the first definition it finds, and that's often a source of hard-to-track bugs. That's why the industry convention is to prefix names with the chart name: myapp.name, myapp.fullname, not generic name or fullname. Third, scope: when a template is called, you must pass . as the context — {{- include "myapp.name" . -}}. This is because inside a named template, . refers to the scope passed by the caller. Forgetting to pass . means .Values inside the helper is empty, and the result is unexpected default values.
{{- define "myapp.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}There are two ways to call a named template: template and include. The difference is fundamental and determines your writing style. template "name" . produces output directly, but its result can't go into a pipeline. Just imagine: {{- template "myapp.labels" . | nindent 4 }} is invalid syntax — template is an action, not a function, so it doesn't return a value that can be fed to nindent. include "name" ., by contrast, is a function that returns a string — so {{- include "myapp.labels" . | nindent 4 }} works perfectly.
The practical implication: include is almost always the right choice. It lets a helper be used as input to another helper — {{- include "myapp.serviceAccountName" . | trunc 63 }} — and its output can be piped to nindent or toYaml. template only fits the "render and leave it in place" case, such as calling a helper that manages its own whitespace. In production charts, you'll find include far more often than template.
Tip
Remember the Helm community's saying: template is for printing, include is for processing. If you don't need to pipe the result into another function, both produce the same output — but the moment you need nindent, default, or upper, only include can do it.
Now let's build a complete _helpers.tpl for a chart named myapp — the standard produced by helm create and used by most charts on Artifact Hub. Each helper has a specific role:
myapp.name — the app's base name, with a fallback to nameOverride.myapp.fullname — the full resource name combining Release.Name and Chart.Name. This is the convention that keeps two releases of the same chart in the same namespace from overwriting each other's resources.myapp.chart — the helm.sh/chart label, format "chart-name-version", with + replaced by _ because labels can't contain +.myapp.labels — the set of recommended standard Kubernetes labels (app.kubernetes.io/*).myapp.selectorLabels — the labels used by the Deployment and Service selectors. Important: a selector must not change after a Deployment is created (immutable), so this helper usually omits app.kubernetes.io/version and helm.sh/chart.myapp.serviceAccountName — determines the ServiceAccount name, with a sensible fallback.{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this
(by the DNS naming spec). If release name contains chart name it will be used as a full name.
*/}}
{{- define "myapp.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "myapp.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}Pay attention to the most important pattern — chart.fullname — because it determines almost every resource name in the chart. The logic chain: if the user sets fullnameOverride, use it. If not, compute the base name from nameOverride or Chart.Name. If the base name is already contained in Release.Name (for example, the release myapp from the chart myapp), just use Release.Name so it doesn't become myapp-myapp. Otherwise, combine them into release-chart. Finally, trunc 63 keeps the name within the DNS limit (RFC 1035), and trimSuffix "-" cleans up any trailing dash left after truncation. This pattern consistency is what keeps every resource — Deployment, Service, Ingress, PVC — using the same name, making debugging easy.
Helpers are useless until called. Here's a Deployment that uses almost every helper above:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "myapp.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
ports:
- name: http
containerPort: {{ .Values.service.port }}
env:
{{- range $key, $value := .Values.env }}
- name: {{ $key | quote }}
value: {{ $value | quote }}
{{- end }}Several techniques appear here. First, the idiom {{- include "myapp.labels" . | nindent 4 }} — the {{- trims whitespace on the left so the helper renders right after the labels: line, and nindent 4 adds a newline then indents the entire output 4 spaces. Without nindent, the YAML would be invalid because the label contents would merge into the labels: line. Second, with .Values.imagePullSecrets restricts scope so inside the block, . points to imagePullSecrets — making toYaml . easy. Third, default .Chart.AppVersion provides an elegant fallback: if no image tag is set in values, the application version from Chart.yaml is used.
Let's see the render result for a release named myapp in a staging environment:
helm template myapp ./myapp-chart \
--namespace staging \
--set env.APP_ENV=stagingNotice that the YAML output is clean with no weird blank lines — that's the result of combining {{-/-}} with nindent. This isn't just aesthetics: YAML is very sensitive to indentation and whitespace, and a single wrong space makes kubectl apply fail with an error converting YAML to JSON.
A good chart offers features that can be turned on or off without editing templates. The standard pattern is a {{- if .Values.ingress.enabled }} guard. Under the hood, this is exactly the if from episode 8 — just used to decide whether a resource gets rendered.
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "myapp.serviceAccountName" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- end }}The same pattern applies to Ingress and PVC. There are two design keys here. First, the guard checks the same values that related helpers use — serviceAccount.create controls whether the resource is created, and serviceAccountName decides which name the Deployment references, so the two never contradict each other. Second, when a resource isn't created, references to it must also be adjusted — for example, if the Ingress is disabled, the Service may be ClusterIP; if enabled, it may need NodePort or LoadBalancer before an ingress controller picks it up. That's a real "feature toggle": one value in values.yaml consistently changes the behavior of several templates.
For environment-specific resources, you don't need to change the template at all — just the values file. Remember the precedence hierarchy from episode 6: values.yaml (defaults) < -f files < --set. So a chart can contain the default ingress.enabled: false, while values-prod.yaml sets it to true with the production host.
{{- and -}} TricksWhitespace is the quietest enemy in Helm templating. Go Template preserves blank lines and spaces from the template, and YAML forgives neither. Let's dissect the two tools that control it:
{{- (left trim) — trims all whitespace (spaces, tabs, newlines) before the action.-}} (right trim) — trims all whitespace after the action.indent N — adds N spaces at the start of each line of the input string.nindent N — adds a newline, then indents like indent.apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
data:
APP_ENV: {{ .Values.appEnv }}
TZ: "UTC"The template above looks clean because each line already starts at the right column. The real problem appears when an action sits in the middle of a flow, for example inside an if surrounded by blank lines, or when calling a helper whose output is multi-line. The most classic case:
metadata:
labels:
{{- if .Values.extraLabels }}
{{- toYaml .Values.extraLabels | nindent 4 }}
{{- end }}Without proper trimming, this block can produce a blank line after labels:, making metadata.labels null in YAML. The safe rule of thumb: always start structure actions (if/range/with/define) with {{-, and end them with -}} unless you genuinely want whitespace preserved (for example, inside text blocks like NOTES.txt). When in doubt, run helm template and inspect the output — rendering never lies.
Warning
toYaml preserves the original indentation from values. That's why the pattern {{- toYaml .Values.podSecurityContext | nindent 8 }} exists: nindent normalizes the whole YAML block to align with the parent key. Using indent without nindent will stick the output to the podSecurityContext: line and break the YAML structure.
The same chart template must be able to produce n resources from one definition. range (from episode 8) is used for iteration, and its shape differs for lists vs maps.
Iterating a list — useful for creating many Ingresses, many environment variables, or many hosts:
{{- range $ingress := .Values.ingresses }}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" $ }}-{{ $ingress.name }}
labels:
{{- include "myapp.labels" $ | nindent 4 }}
spec:
ingressClassName: {{ $ingress.className }}
rules:
- host: {{ $ingress.host | quote }}
http:
paths:
- path: {{ $ingress.path }}
pathType: Prefix
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $ingress.port }}
{{- end }}Two important details: first, inside range, the . scope moves to the list element — so to access chart values, use $ (the root context) like {{ include "myapp.fullname" $ }}. Second, each iteration is preceded by --- to separate YAML documents, because one template file may produce several manifests — this is the feature that enables "multiple resources from one template." Iterating a map follows the range $key, $value := .Values.env pattern, as we saw in the Deployment: the key-value pairs of a YAML map become name/value pairs in an env array.
To close this episode, here are the disciplines that separate a production chart from a chart that merely "can be installed":
{{/* ... */}} — explain what it does and why, not just how. Template comments are visible in the _helpers.tpl file and become living documentation for other team members.chart.fullname pattern. Don't mix trunc 63 in one helper and ignore it in another — one chart, one naming convention, and use the same helper in all templates._helpers.tpl; manifest structure in templates; and changeable values in values.yaml. Never hardcode a value that should be a values entry.helm lint and helm template --debug after every change. The linter catches structural errors; the render catches output errors. These two small tools save you most of your debugging time.helm lint ./myapp-chart
helm template myapp ./myapp-chart --debug > /tmp/rendered.yamlIn episode 10 you've moved your templating ability from "writing syntax" to "designing abstraction": understanding _helpers.tpl's role as a shared function library, distinguishing template (an action, can't be piped) from include (a function, can be piped), building the standard helpers — name, fullname, chart, labels, selectorLabels, serviceAccountName — with a consistent chart.fullname pattern, rendering optional resources via conditional rendering, mastering whitespace with {{-/-}} plus indent/nindent, and creating many resources from one template via range. Plus a set of best practices that keep charts clean and deterministic.
This is the arsenal that sets you apart from merely being a user of other people's charts: now you can build your own charts to a standard equal to the popular charts on Artifact Hub. In the next episode, episode 11, we'll widen the horizon with dependencies and subcharts — how to compose large charts from small charts, share values via global, override subchart values, and advanced patterns like import-values, condition, tags, and alias. Make sure this episode's foundation really sticks, because you'll reuse every pattern we built today in every following episode. See you in episode 11!