StackPractices
intermediate By Mathias Paulenko

Helm Chart Templating: Package Kubernetes Manifests

Build a production-ready Helm chart from scratch: values, helpers, conditionals, ranges, hooks, subcharts, packaging, and OCI registries.

Topics: devops

Overview

Maintaining one Kubernetes manifest per environment doesn’t scale. You copy deployment.yaml, tweak replicas and image tags for staging, tweak them again for production, and after a few releases the copies drift apart — nobody remembers which one is canonical. Helm treats the manifests as templates instead: one chart holds the structure, values.yaml holds the defaults, and each environment gets a small override file. When you run helm install, Helm renders the templates with your values, applies the result to the cluster, and records the release so upgrades and rollbacks stay reproducible.

This recipe builds a complete chart — values, helpers, conditionals, loops, hooks, subcharts, and library charts — then packages and distributes it. For installing and managing releases in CI/CD rather than authoring charts, see Deploy Applications to Kubernetes with Helm Charts.

When to Use

  • Deploying the same app to several environments (dev, staging, production) where only configuration differs
  • Sharing a reusable deployment across teams — consumers override values instead of editing YAML
  • Managing an application’s lifecycle (install, upgrade, rollback) with release tracking
  • Parameterizing manifests: replicas, images, resource limits, feature toggles per environment
  • Vendoring third-party tools (Prometheus, Grafana, ingress controllers) through their published charts

When NOT to Use

  • A single environment with static manifests — plain kubectl apply -f has fewer moving parts
  • GitOps-managed clusters where ArgoCD or Flux owns the apply loop (though both can consume Helm charts)
  • Manifests that change rarely and need no parameterization
  • Teams that already standardize on Kustomize overlays or jsonnet and don’t want Go templates

Solution

Chart structure

my-chart/
├── Chart.yaml          # Chart metadata and dependencies
├── .helmignore         # Files excluded from the packaged chart
├── values.yaml         # Default values
├── values/
│   ├── staging.yaml    # Environment overrides
│   └── production.yaml
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── configmap.yaml
│   ├── ingress.yaml
│   ├── _helpers.tpl    # Named templates
│   └── NOTES.txt       # Post-install notes
└── charts/             # Downloaded subchart dependencies

Chart.yaml

apiVersion: v2
name: my-app
description: A Helm chart for my application
type: application
version: 1.0.0          # Chart version
appVersion: "2.5.0"     # App version
keywords:
  - web
  - api
maintainers:
  - name: Mathias Paulenko
    email: mathias@example.com
dependencies:
  - name: postgresql
    version: 12.x.x
    repository: oci://registry-1.docker.io/bitnamicharts
    condition: postgresql.enabled

values.yaml

replicaCount: 2

image:
  repository: my-app
  tag: "2.5.0"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8080

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

ingress:
  enabled: false
  className: nginx
  hosts:
    - host: my-app.example.com
      paths:
        - path: /
          pathType: Prefix

config:
  LOG_LEVEL: info
  DATABASE_URL: ""

postgresql:
  enabled: false

Deployment template

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.port }}
              protocol: TCP
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          env:
            - name: LOG_LEVEL
              value: {{ .Values.config.LOG_LEVEL | quote }}
            - name: DATABASE_URL
              value: {{ .Values.config.DATABASE_URL | quote }}

Service template

# templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: http
      protocol: TCP
  selector:
    {{- include "my-app.selectorLabels" . | nindent 4 }}

Helper templates

# templates/_helpers.tpl
{{- define "my-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "my-app.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 }}

{{- define "my-app.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{ include "my-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Conditional blocks

# templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "my-app.fullname" . }}
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: {{ .Values.ingress.className }}
  {{- with .Values.ingress.tls }}
  tls:
    {{- toYaml . | nindent 4 }}
  {{- end }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "my-app.fullname" $ }}
                port:
                  number: {{ $.Values.service.port }}
          {{- end }}
    {{- end }}
{{- end }}

Range over values

# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "my-app.fullname" . }}
data:
  {{- range $key, $value := .Values.config }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

Environment-specific values

# values/production.yaml
replicaCount: 5

image:
  tag: "2.5.0-prod"

resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 2000m
    memory: 2Gi

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: my-app.production.com
      paths:
        - path: /
          pathType: Prefix

config:
  LOG_LEVEL: warn
  DATABASE_URL: "postgres://prod-db:5432/myapp"

Helm hooks

# templates/post-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "my-app.fullname" . }}-post-install
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate", "up"]
          env:
            - name: DATABASE_URL
              value: {{ .Values.config.DATABASE_URL | quote }}

The complete chart — Chart.yaml, values.yaml, per-environment overrides, and every template below — is runnable as-is in the companion repository.

Installing and upgrading

# Install with default values
helm install my-app ./my-chart

# Install with custom values
helm install my-app ./my-chart -f values/production.yaml

# Install with inline overrides
helm install my-app ./my-chart --set replicaCount=5 --set image.tag=2.5.0

# Upgrade
helm upgrade my-app ./my-chart -f values/production.yaml

# Rollback
helm rollback my-app 1

# Uninstall
helm uninstall my-app

# Template (dry-run, render manifests)
helm template my-app ./my-chart -f values/production.yaml > manifests.yaml

# Lint
helm lint ./my-chart

How It Works

When you run helm install, the CLI executes every file under templates/ as a Go template against the merged values, then sends the rendered manifests to the Kubernetes API. If the apply succeeds, Helm stores the release as a Secret in the cluster — that record is what the upgrade, rollback, and history commands rely on.

Mermaid flowchart LR diagram

Template context objects

Besides .Values, templates can read several built-in objects:

ObjectWhat it exposes
.ValuesMerged values from values.yaml, -f files, and --set flags
.ReleaseRelease name, namespace, revision, and install/upgrade state
.ChartFields from Chart.yaml such as name, version, and appVersion
.CapabilitiesCluster info — Kubernetes version and available API groups
.FilesNon-template files in the chart, for embedding raw data

Value precedence

When several sources define the same key, Helm applies them in order and the last write wins:

  1. values.yaml inside the chart holds the defaults
  2. -f files apply left to right, so later files override earlier ones
  3. --set, --set-string, and --set-file flags have the highest precedence

A key that’s never defined renders as nil. Guard optional blocks with {{ if .Values.x }}, supply fallbacks with {{ .Values.x | default "y" }}, or fail fast with {{ required "database.host is required" .Values.database.host }} so consumers get a clear error instead of a broken manifest.

Inspecting the rendered output

Always render before installing — helm template shows exactly what will reach the API server, and helm show values ./my-chart prints the merged defaults a consumer would override:

helm template my-app ./my-chart -f values/production.yaml
# Rendered output (excerpt)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  labels:
    helm.sh/chart: my-app-1.0.0
    app.kubernetes.io/name: my-app
    app.kubernetes.io/instance: my-app
    app.kubernetes.io/version: "2.5.0"
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 5

Packaging and distributing a chart

helm package bundles the chart directory into a versioned .tgz archive. Run helm dependency update first — it resolves subcharts into charts/ and writes Chart.lock, which you should commit so every build pins the same dependency versions. .helmignore works like .gitignore for the packaged archive.

helm dependency update ./my-chart   # writes Chart.lock, pulls subcharts
helm package ./my-chart             # produces my-chart-1.0.0.tgz

# OCI registries (Helm 3.8+): push like a container image
helm push my-chart-1.0.0.tgz oci://registry.example.com/charts

# Classic chart repository: generate the index
helm repo index ./repo --url https://charts.example.com

Debugging render errors

Two errors account for most broken templates:

  • nil pointer evaluating .Values.database.host — the template referenced a key that doesn’t exist. Add a default, or mark the key as required so Helm fails fast.
  • error converting YAML to JSON: yaml: line 12 — the template rendered invalid YAML. Run helm template and inspect the output file — a missing quote or a misplaced nindent is the usual cause.

For a release that’s already installed, helm get manifest my-app prints the rendered manifests Helm last applied, which is the fastest way to compare what’s live against what the chart produces locally.

Variants

Using subcharts

# Chart.yaml
dependencies:
  - name: redis
    version: 17.x.x
    repository: oci://registry-1.docker.io/bitnamicharts
    condition: redis.enabled
# values.yaml — configure subchart
redis:
  enabled: true
  auth:
    password: "my-redis-password"
  architecture: standalone

Library chart (reusable blocks)

# Chart.yaml
apiVersion: v2
name: common
type: library          # Library chart — not deployable
version: 1.0.0
# templates/_container.tpl
{{- define "common.container" -}}
- name: {{ .name }}
  image: {{ .image }}
  ports:
    - containerPort: {{ .port }}
  resources:
    {{- toYaml .resources | nindent 4 }}
{{- end }}
# In another chart's template
{{- include "common.container" (dict "name" "api" "image" "my-api:1.0" "port" 8080 "resources" .Values.resources) | nindent 8 }}

Helm with sealed secrets

# templates/sealedsecret.yaml
{{- if .Values.sealedSecret.enabled }}
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: {{ include "my-app.fullname" . }}
spec:
  encryptedData:
    database-password: {{ .Values.sealedSecret.encryptedPassword | quote }}
  template:
    metadata:
      name: {{ include "my-app.fullname" . }}
    type: Opaque
{{- end }}

Best Practices

  • For a deeper guide, see Helm Charts: Structure, Templating, Dependencies, Registry.

  • Prefer include over template — only include returns a string you can pipe through nindent, and you need that almost everywhere.

  • Always use nindent instead of indent for nested YAML — it prepends the newline, so the block lands on its own line.

  • Set resource requests and limits in values.yaml so each environment can tune them without editing templates.

  • Quote string values with {{ .Values.x | quote }} — YAML reads bare yes/no/on/off and version-like numbers as booleans or floats.

  • Keep values.yaml as the safe default and put environment overrides in separate files, not in long --set chains inside CI scripts.

  • Run helm lint and helm template in CI before any helm install — they catch syntax and rendering errors without touching the cluster.

  • Pin subchart versions in Chart.yaml and commit Chart.lock, or an upstream release will silently change your rendered manifests.

  • Before merging chart changes in a shared repo, run through the Helm chart review checklist.

Common Mistakes

  • Using indent where nindent is needed: indent pads lines but doesn’t add a leading newline, so the first line lands next to the parent key and the manifest is malformed. Reach for nindent by default.
  • Not quoting values: version: {{ .Values.version }} with version: "2.0" renders as version: 2.0, which YAML parses as a float — and on/off/yes/no become booleans. Pipe string values through quote so they stay literal.
  • Hardcoding values in templates: if a value varies between environments or consumers, move it to values.yaml; otherwise the chart can’t be reused and the whole point of templating is lost.
  • Skipping helm lint and helm template in CI: template errors surface at install time, often while the pipeline holds a deployment lock. Render and lint first.
  • Overriding everything with --set: long --set chains are hard to review and leak values into CI logs. Files passed with -f are versioned, diffable, and easier to audit.
  • Editing files under charts/: downloaded subcharts get overwritten on the next helm dependency update. Patch behavior through values, or fork the upstream chart if you need real changes.

See Also

Frequently Asked Questions

What is a Helm chart?

A Helm chart is a bundle of templated Kubernetes manifests plus a values.yaml file. You override the values per environment, install with helm install, and Helm tracks each deployment as a release you can upgrade or roll back.

How is Helm different from Kustomize?

Helm parameterizes manifests with Go templates, so values can come from flags, files, or consumers of a shared chart. Kustomize avoids templating entirely — it layers patches over a base. Helm fits better when you distribute charts; Kustomize fits better for team-internal environment overlays.

What is a Helm hook?

A hook is a manifest annotated with helm.sh/hook that Helm runs at a specific point in the release lifecycle — pre-install, post-upgrade, pre-rollback, and so on. Database migrations and backup jobs are the classic use case.

Can I use Helm with ArgoCD or Flux?

Yes — both GitOps tools deploy Helm charts. ArgoCD renders them natively; Flux uses a HelmRelease CRD. In that setup Helm still does the templating, but the GitOps controller owns when changes get applied.

How do I debug a template?

Render locally with helm template my-app ./my-chart -f values.yaml, or use helm install --dry-run --debug to see the manifests plus the computed values without touching the cluster. For an installed release, helm get manifest shows what Helm actually applied.

Where should I publish a Helm chart?

Since Helm 3.8 the recommended option is an OCI registry — you push charts with helm push to the same container registry you already use. The older approach still works too: host the .tgz files and a generated index.yaml behind any static file server as a classic chart repository.