Helm & Kubernetes Packaging

Named Templates & Helpers

18 min Lesson 5 of 28

Named Templates & Helpers

By lesson 4 you know that Helm templates are Go text/template files wired to a .Values tree. But a real-world chart for a production application has ten or more templates: Deployment, Service, Ingress, HPA, PDB, ServiceAccount, RBAC, ConfigMap, NetworkPolicy, and more. Every one of them needs the same Kubernetes label set, the same selector labels, the same name helper that trims to 63 characters, and the same chart/app version annotations. Without a shared location for this logic you copy-paste the same twelve lines into every file, and when the label standard changes you have ten files to update — and you will miss one.

The solution is _helpers.tpl: a file of named templates (also called partials) that centralises reusable logic. This lesson covers the mechanics thoroughly, because getting this layer right is the difference between a maintainable chart library and a mess of duplicated YAML.

How Named Templates Work: define and include

Helm's Go template engine exposes two directives for sharing logic across files:

  • {{ define "name" }}{{ end }} — declares a named template block. Any file in templates/ can define one, but convention dictates putting all of them in templates/_helpers.tpl. Files whose names begin with an underscore are never rendered as manifests — they exist purely to contribute definitions to the shared namespace.
  • {{ include "name" . }} — renders a named template in the current scope (the dot .) and returns the result as a string. This is the right primitive for injecting partial output into a parent document.
  • {{ template "name" . }} — identical in semantics but does not return the rendered string; it writes directly to the output stream. This makes it impossible to pipe through nindent or trim, so in practice you should always use include.
Production pitfall — template vs include: A common chart bug is using {{ template "myapp.labels" . }} directly in a metadata: block instead of {{ include "myapp.labels" . | nindent 4 }}. The template directive cannot be piped, so indentation is hardcoded in the partial itself and breaks the moment you use it at a different nesting depth. Always prefer include.

Anatomy of _helpers.tpl

Every chart generated by helm create ships a starter _helpers.tpl. Understanding each named template in it is prerequisite to writing your own. Below is the canonical production version for a chart named myapp:

{{/* Expand the name of the chart. Helm's default limits resource names to 63 chars (Kubernetes DNS label limit). */}} {{- define "myapp.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. Combines release name + chart name; capped at 63 chars. If the release name already contains the chart name, use the release name alone. */}} {{- 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 }} {{/* Chart label: "helm.sh/chart: myapp-1.2.3" Strips any "+" from the version so it is a valid label value. */}} {{- define "myapp.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels — applied to every resource for discoverability. These are SELECTOR labels plus extras for operational tooling. */}} {{- 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 — the subset used in matchLabels and Service selectors. NEVER change these after initial deploy; changing selector labels requires deleting and recreating the Deployment (they are immutable in the cluster). */}} {{- define "myapp.selectorLabels" -}} app.kubernetes.io/name: {{ include "myapp.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* ServiceAccount name helper. */}} {{- define "myapp.serviceAccountName" -}} {{- if .Values.serviceAccount.create }} {{- default (include "myapp.fullname" .) .Values.serviceAccount.name }} {{- else }} {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }}

Notice several critical patterns baked into this template:

  • The {{- … -}} dash trimming removes all whitespace before and after the block, keeping rendered YAML clean.
  • trunc 63 | trimSuffix "-" prevents Kubernetes DNS label validation errors when release names are long (e.g., in ArgoCD the release name often includes the environment and Git branch).
  • selectorLabels is a strict subset of labels. Service selector: and Deployment matchLabels: use selectorLabels; metadata.labels uses the full labels template. This separation is essential.

Using Helpers in Templates

Here is how the Deployment template consumes the helpers, showing the exact indentation rules:

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 }} annotations: checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} spec: 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 }} protocol: TCP

The nindent N function prepends a newline then indents the entire multi-line string by N spaces. This is why include is mandatory: nindent is a string pipeline function — it cannot accept the void output of template.

The checksum/config annotation trick: Adding checksum/config: {{ include … | sha256sum }} to the pod template causes Helm to roll the Deployment automatically whenever the referenced ConfigMap changes. Without this, an helm upgrade that only modifies a ConfigMap produces zero pod restarts — your app keeps running with stale config. This annotation is a production standard at every major tech company.

Writing Custom Helpers

Beyond the standard label helpers, you will frequently write domain-specific partials. Common examples from production charts:

{{/* Environment variable block shared by all containers in the release. Produces a YAML list starting with "- name:". Usage: {{- include "myapp.commonEnv" . | nindent 12 }} */}} {{- define "myapp.commonEnv" -}} - name: APP_ENV value: {{ .Values.global.environment | quote }} - name: LOG_LEVEL value: {{ .Values.logLevel | default "info" | quote }} - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - name: POD_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace {{- end }} {{/* Resource block — centralises requests/limits so every workload in the chart gets consistent resource management. Usage: {{- include "myapp.resources" .Values.resources | nindent 10 }} Note: pass .Values.resources (not the full dot) so the helper is scope-agnostic. */}} {{- define "myapp.resources" -}} {{- if . }} resources: {{- toYaml . | nindent 2 }} {{- end }} {{- end }}

The Kubernetes Label Standard

The labels produced by the helper templates follow the Kubernetes Recommended Labels spec (app.kubernetes.io/*). These are not arbitrary — tooling in the ecosystem (Prometheus ServiceMonitors, Datadog autodiscovery, ArgoCD, Lens) queries resources by these labels. Getting them right unlocks observability for free.

Named template call flow in a Helm chart _helpers.tpl define "myapp.name" define "myapp.fullname" define "myapp.labels" define "myapp.selectorLabels" define "myapp.commonEnv" deployment.yaml include "myapp.labels" include "myapp.selectorLabels" service.yaml include "myapp.selectorLabels" ingress.yaml include "myapp.labels" Rendered YAML app.kubernetes.io/name: myapp app.kubernetes.io/instance: prod app.kubernetes.io/version: "2.1.0" helm.sh/chart: myapp-1.0.0 app.kubernetes.io/managed-by: Helm
All template files call into the shared named templates in _helpers.tpl, producing a consistent label set across every rendered manifest.

The full set of recommended labels to emit on every resource:

  • app.kubernetes.io/name — the application name (not the release name). Used by Prometheus to auto-discover ServiceMonitors.
  • app.kubernetes.io/instance — the release name (unique per install). Lets you run multiple instances of the same app in one namespace.
  • app.kubernetes.io/version — the application version (usually .Chart.AppVersion). Feeds Datadog's version tracking.
  • app.kubernetes.io/managed-by — always Helm. Used by helm list to discover releases.
  • helm.sh/chart — chart name + version. Used by ArgoCD to detect chart drift.
Big-tech pattern — global labels via a top-level helper: Platform teams at companies like Lyft and Stripe extend the label set with cost-attribution labels: team: platform, cost-center: infrastructure, env: prod. These are injected via a myapp.globalLabels helper that reads from .Values.global. Every resource in every chart in the company emits these labels, so cloud cost tooling (Kubecost, Infracost) can break down spend by team and environment automatically — no manual tagging.

Validating Rendered Output

You should lint your helpers before ever applying to a cluster. The three commands every chart author must know:

# Render all templates locally — shows exactly what would be applied helm template myapp ./mychart --values values-prod.yaml # Lint: catches syntax errors, missing required values, bad YAML helm lint ./mychart --values values-prod.yaml # Dry-run against a live cluster — uses the API server for validation # (catches invalid resource structures that helm lint misses) helm install myapp ./mychart \ --values values-prod.yaml \ --dry-run \ --namespace staging # Debug a specific template expansion step-by-step helm template myapp ./mychart \ --debug \ --values values-prod.yaml \ 2>&1 | grep -A 20 "deployment.yaml"

Run helm template in your CI pipeline on every pull request. A broken template renders broken YAML before it ever reaches the cluster, and the pipeline catches it before a reviewer even looks at the diff.

Key takeaway: _helpers.tpl is not a convention — it is the architectural foundation of a maintainable Helm chart. Define your label standard once, enforce the include … | nindent N pattern everywhere, add the checksum/config annotation on pod templates, and your chart becomes a first-class Kubernetes citizen that plays well with the entire observability and GitOps tooling ecosystem.