intermediate
devops

Helm Charts

Package applications with Helm, values files, and chart templating

kubernetes fundamentals
28 min read

Helm Charts

Once your application grows past a single manifest, copy-pasting near-identical YAML for dev, staging, and prod becomes a maintenance nightmare. Helm is the de-facto package manager for Kubernetes: it bundles all the manifests an application needs into a versioned, templated, shareable unit called a chart. Instead of applying a dozen raw files with kubectl, you install one chart, override a handful of values per environment, and get atomic upgrades and rollbacks for free.

Helm 3 (the current major version) is client-only — there is no server-side "Tiller" component anymore. The Helm CLI talks directly to the Kubernetes API server using your existing kubeconfig, and it stores the state of each installation as a Secret in the release's namespace. This lesson covers how charts are structured, how templating works, and how to manage the full lifecycle of a release.

Learning Objectives
  • Understand the chart directory structure and Chart.yaml metadata
  • Parameterize manifests with values.yaml and per-environment overrides
  • Template resources with Go templates, named helpers, and pipelines
  • Install, upgrade, roll back, and inspect releases
  • Manage subchart dependencies and remote chart repositories

Why Helm Instead of Raw kubectl?

A realistic web app needs a Deployment, a Service, an Ingress, a ConfigMap, an HPA, and maybe a ServiceAccount — six or more manifests that share the same labels, image tag, and replica count. Helm solves three problems at once:

  • Templating — write the manifest once, inject values like the image tag or replica count from a single source.
  • Packaging — ship all resources plus their default configuration as one versioned artifact you can publish to a registry.
  • Lifecycle — install, upgrade, rollback, and uninstall are tracked as revisions, so you can undo a bad deploy with one command.

Anatomy of a Chart

A chart is just a directory with a required layout. Running helm create myapp produces this structure:

bash
myapp/ ├── Chart.yaml # Chart metadata: name, version, appVersion, dependencies ├── values.yaml # Default configuration values ├── .helmignore # Files to exclude when packaging ├── charts/ # Vendored subchart dependencies (.tgz archives) └── templates/ # Manifest templates rendered with Go templating ├── _helpers.tpl # Reusable named template definitions ├── deployment.yaml ├── service.yaml ├── ingress.yaml └── NOTES.txt # Usage notes printed after install

The Chart.yaml file is the manifest of the package itself. Note that version is the chart's own SemVer, while appVersion is the version of the application it deploys — they change independently.

yaml
# Chart.yaml apiVersion: v2 name: myapp description: A Helm chart for the myapp web service type: application version: 0.2.0 # Version of THIS chart (SemVer) appVersion: "1.4.0" # Version of the app being deployed

Values: One Source of Configuration

values.yaml holds the default configuration. Every value is exposed to templates under the .Values object. You keep environment-specific differences in separate files that override only what changes.

yaml
# values.yaml (defaults) replicaCount: 2 image: repository: registry.example.com/myapp tag: "" # Empty → fall back to Chart.appVersion pullPolicy: IfNotPresent service: type: ClusterIP port: 80 targetPort: 8080 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 256Mi
yaml
# values.prod.yaml (overrides only) replicaCount: 6 image: tag: "1.4.0" resources: requests: cpu: 250m memory: 256Mi

Templating with Go Templates

Templates live in templates/ and use Go's templating engine, wrapping directives in {{ }}. Helm exposes several built-in objects: .Values (your config), .Release (name, namespace, revision), .Chart (metadata from Chart.yaml), and .Capabilities (cluster/API info).

yaml
# templates/deployment.yaml 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: containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - containerPort: {{ .Values.service.targetPort }} resources: {{- toYaml .Values.resources | nindent 12 }}

Two patterns above are worth understanding. {{ .Values.image.tag | default .Chart.AppVersion }} is a pipeline — it feeds the tag into the default function so an empty value falls back to the chart's appVersion. And {{- toYaml .Values.resources | nindent 12 }} renders the whole resources map as YAML indented 12 spaces; the leading {{- trims the preceding whitespace so the output lines up cleanly.

Named Templates and Helpers

Repeated snippets — like the standard label set — go in _helpers.tpl as named templates you reuse with include. Files whose names start with _ are treated as partials and never rendered into manifests on their own.

yaml
# templates/_helpers.tpl {{- define "myapp.fullname" -}} {{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} {{- end -}} {{- define "myapp.selectorLabels" -}} app.kubernetes.io/name: {{ .Chart.Name }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end -}} {{- define "myapp.labels" -}} {{ include "myapp.selectorLabels" . }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end -}}

Guarding Against Missing Values

Use conditionals and the required function to fail fast when a mandatory value is absent, rather than shipping a broken manifest.

yaml
{{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "myapp.fullname" . }} spec: rules: - host: {{ required "ingress.host is required when ingress is enabled" .Values.ingress.host }} {{- end }}

Managing Releases

An installed instance of a chart is a release. Helm tracks every change as a numbered revision, which is what makes rollbacks trivial.

Install a new release into a namespace, creating it if needed: helm install myapp ./myapp -n prod --create-namespace -f values.prod.yaml

The everyday CI/CD command is the idempotent upgrade — it works whether or not the release already exists, and --atomic automatically rolls back if the upgrade fails to become healthy within the timeout:

bash
helm upgrade --install myapp ./myapp \ --namespace prod \ --create-namespace \ -f values.prod.yaml \ --set image.tag=1.4.1 \ --atomic \ --timeout 5m

Values Precedence

When the same key is set in multiple places, later sources win. The order is: chart values.yaml → each -f file in the order given → each --set flag. So --set always overrides files, which override the chart defaults.

Dependencies and Repositories

Charts can depend on other charts (subcharts) — for example, pulling in a database instead of maintaining your own. Declare dependencies in Chart.yaml, then run helm dependency update to fetch them into charts/ and write a Chart.lock.

yaml
# Chart.yaml dependencies: - name: postgresql version: "15.5.x" repository: "https://charts.bitnami.com/bitnami" condition: postgresql.enabled # Toggle the subchart via values
bash
# Add a repo, refresh its index, and search it helm repo add bitnami https://charts.bitnami.com/bitnami helm repo update helm search repo bitnami/postgresql # Vendor the declared dependencies into charts/ helm dependency update ./myapp

You configure a subchart by nesting its values under a key matching its name (postgresql: in the parent values.yaml). The condition field lets consumers disable it entirely with postgresql.enabled: false.

Debugging and Validation

Never push a chart without rendering it first. helm template prints the final manifests without touching the cluster, and helm lint catches structural problems.

bash
# Render manifests locally with prod values — no cluster changes helm template myapp ./myapp -f values.prod.yaml # Static analysis of chart structure and required fields helm lint ./myapp # Dry-run against the API server (validates but does not apply) helm upgrade --install myapp ./myapp --dry-run=server

Best Practices and Common Pitfalls

Ship Reliable Charts
  • Version discipline — bump version on every chart change and appVersion when the app image changes; they are independent.
  • Document every value — keep values.yaml complete with sane defaults and comments so consumers know what is configurable.
  • Prefer idempotent deploys — always use helm upgrade --install in automation, never a bare helm install that fails on the second run.
  • Fail fast — wrap mandatory values in required so a missing setting errors at render time, not at runtime in the cluster.
  • Pin dependencies — commit Chart.lock so builds are reproducible instead of pulling a new subchart patch silently.

Next Steps

You can now package, template, and safely roll out applications with Helm. The next step is knowing whether those releases are actually healthy — the domain of observability.

Continue Learning
Instrument your workloads and cluster with Prometheus and Grafana for metrics, dashboards, and alerts.

Next Lesson

Monitoring & Logging