Helm Chart Templating: Package Kubernetes Manifests
Build a production-ready Helm chart from scratch: values, helpers, conditionals, ranges, hooks, subcharts, packaging, and OCI registries.
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 -fhas 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.
Template context objects
Besides .Values, templates can read several built-in objects:
| Object | What it exposes |
|---|---|
.Values | Merged values from values.yaml, -f files, and --set flags |
.Release | Release name, namespace, revision, and install/upgrade state |
.Chart | Fields from Chart.yaml such as name, version, and appVersion |
.Capabilities | Cluster info — Kubernetes version and available API groups |
.Files | Non-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:
values.yamlinside the chart holds the defaults-ffiles apply left to right, so later files override earlier ones--set,--set-string, and--set-fileflags 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. Runhelm templateand inspect the output file — a missingquoteor a misplacednindentis 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
includeovertemplate— onlyincludereturns a string you can pipe throughnindent, and you need that almost everywhere. -
Always use
nindentinstead ofindentfor nested YAML — it prepends the newline, so the block lands on its own line. -
Set resource requests and limits in
values.yamlso each environment can tune them without editing templates. -
Quote string values with
{{ .Values.x | quote }}— YAML reads bareyes/no/on/offand version-like numbers as booleans or floats. -
Keep
values.yamlas the safe default and put environment overrides in separate files, not in long--setchains inside CI scripts. -
Run
helm lintandhelm templatein CI before anyhelm install— they catch syntax and rendering errors without touching the cluster. -
Pin subchart versions in
Chart.yamland commitChart.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
indentwherenindentis needed:indentpads lines but doesn’t add a leading newline, so the first line lands next to the parent key and the manifest is malformed. Reach fornindentby default. - Not quoting values:
version: {{ .Values.version }}withversion: "2.0"renders asversion: 2.0, which YAML parses as a float — andon/off/yes/nobecome booleans. Pipe string values throughquoteso 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 lintandhelm templatein CI: template errors surface at install time, often while the pipeline holds a deployment lock. Render and lint first. - Overriding everything with
--set: long--setchains are hard to review and leak values into CI logs. Files passed with-fare versioned, diffable, and easier to audit. - Editing files under
charts/: downloaded subcharts get overwritten on the nexthelm dependency update. Patch behavior through values, or fork the upstream chart if you need real changes.
See Also
- Chart Template Guide — the official walkthrough of Helm’s template language
- Template Function List — every Sprig and Helm function available in templates
- Chart Best Practices — official conventions for chart authors
- OCI-based registries — distributing charts through container registries
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.
Related Resources
Helm Charts: Structure, Templating, Dependencies, Registry
Master Helm charts for Kubernetes: chart structure, templating, values, dependencies, hooks, libraries, registry management, and production patterns for deployment.
RecipeDeploy Applications to Kubernetes with Helm Charts
Package, version, and deploy Kubernetes applications using Helm charts with value overrides, template functions, and release management for reproducible infrastructure
DocHelm Chart Review Checklist
A checklist for reviewing Helm charts covering values, templates, security, resource limits, probes, RBAC, and best practices.
RecipeMount Configs and Secrets into Kubernetes Pods
How to mount ConfigMaps and Secrets into Kubernetes pods using env vars, volumes, projected volumes, and secret management with external secrets.