Skip to content

Helm Charts: Deep Dive

This document explains how Helm works under the hood. It covers the problem Helm solves, how charts are structured, how Go templates produce Kubernetes manifests, how Helm tracks releases, and the patterns you will see in real-world charts. All examples reference the simple-web-app chart in this demo.


Kubernetes manifests are static YAML. That works fine when you have one environment and one team. It breaks down fast.

Consider deploying the same application to dev, staging, and production. Without Helm, you end up with near-identical copies of every manifest, differing only in replica counts, image tags, or resource limits. Three environments means three copies of your Deployment, three copies of your Service, and so on. Change a label pattern and you have to change it in every copy.

Teams hit three problems:

  1. Duplication. Copy-pasting manifests across environments leads to drift. One environment gets updated, another does not.
  2. No packaging. There is no standard way to bundle related resources (Deployment + Service + ConfigMap) into a single deployable unit.
  3. No lifecycle management. Raw kubectl apply has no concept of upgrades, rollbacks, or release history. You are on your own to track what is deployed and what version it is.

Helm solves all three. It gives you a templating layer over YAML, a packaging format (the chart), and a release lifecycle with install, upgrade, rollback, and uninstall.


A Helm chart is a directory with a specific layout. Here is the structure for this demo:

chart/
Chart.yaml # Chart metadata
values.yaml # Default configuration values
templates/
_helpers.tpl # Reusable template functions
deployment.yaml # Templated Deployment
service.yaml # Templated Service

There is also an optional charts/ directory for sub-charts (dependencies). This demo does not use one, but large applications often decompose into sub-charts for each microservice.

This is the chart’s identity card. From chart/Chart.yaml:

apiVersion: v2
name: simple-web-app
description: A simple web application Helm chart for ArgoCD demo
type: application
version: 0.1.0
appVersion: "1.0.0"

Key fields:

  • apiVersion: v2 signals this is a Helm 3 chart. Helm 2 used v1. The difference matters because Helm 3 removed Tiller (the server-side component) and changed how releases are stored.
  • name becomes the chart’s identifier. It shows up in template objects like .Chart.Name.
  • version is the chart version. This is the version of the packaging, not the application. Bump this when you change templates, defaults, or chart structure.
  • appVersion is the version of the application being deployed. In this case, "1.0.0". It has no functional impact on Helm, but it shows up in labels (see _helpers.tpl line 38) and in helm list output.
  • type: application means this chart deploys resources. The alternative is library, which provides only helper templates for other charts to import.

This file defines every configurable parameter and its default. From chart/values.yaml:

replicaCount: 2
image:
repository: nginx
tag: "1.25.3-alpine"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 80
resources:
requests:
memory: "32Mi"
cpu: "25m"
limits:
memory: "64Mi"
cpu: "50m"
env:
APP_NAME: "Helm Demo App"
ENVIRONMENT: "development"

Think of values.yaml as the contract between the chart author and the chart user. The author decides what is configurable. The user overrides only what they need to change.

Notice the nested structure. image.repository and image.tag are separate values. In templates, they are accessed as .Values.image.repository and .Values.image.tag. This keeps related settings grouped logically.

The empty map/list defaults at the bottom (nodeSelector: {}, tolerations: [], affinity: {}) are a common pattern. They exist so templates can reference them without nil errors, while making it obvious that nothing is configured by default.

Every .yaml file in this directory is a Go template. Helm renders each one by injecting values and built-in objects, then sends the resulting plain YAML to Kubernetes.

Files starting with _ (like _helpers.tpl) are not rendered into manifests. They contain reusable template definitions that other templates call.


Helm templates use Go’s text/template package with Sprig functions added on top. If you have never seen Go templates, the syntax looks unusual at first. It is simpler than it appears.

Everything inside double curly braces is a template action. Everything outside is literal YAML that passes through unchanged.

From chart/templates/deployment.yaml, line 8:

replicas: {{ .Values.replicaCount }}

Helm evaluates .Values.replicaCount, looks it up in the merged values (default is 2), and produces:

replicas: 2

The dash trims whitespace. {{- trims whitespace before the action. -}} trims whitespace after. This matters because Go templates are whitespace-sensitive, and stray blank lines or indentation in YAML will cause parse errors.

From _helpers.tpl, line 4:

{{- define "simple-web-app.name" -}}

Both dashes ensure the definition itself produces no extra whitespace.

Pipelines chain functions using |, just like Unix pipes. The output of the left side becomes the last argument of the right side.

From _helpers.tpl, line 5:

{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}

Reading left to right:

  1. default .Chart.Name .Values.nameOverride returns .Values.nameOverride if set, otherwise .Chart.Name.
  2. | trunc 63 truncates to 63 characters (Kubernetes label length limit).
  3. | trimSuffix "-" removes a trailing dash if truncation left one.

Another example from deployment.yaml, line 31:

{{- toYaml .Values.resources | nindent 12 }}

toYaml converts the .Values.resources map to YAML text. nindent 12 adds a newline and indents every line by 12 spaces. This is how you inject a multi-line YAML block at the right indentation level.

From _helpers.tpl, lines 37-39:

{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}

If .Chart.AppVersion is set (non-empty, non-nil), the version label is included. Otherwise, it is omitted entirely. The quote function wraps the value in double quotes, which is important because version strings like "1.0.0" should be treated as strings, not floats.

A more complex conditional from _helpers.tpl, lines 11-22:

{{- define "simple-web-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 }}

This fullname helper handles three cases:

  1. If the user provides fullnameOverride, use it directly.
  2. If the release name already contains the chart name, use just the release name (avoids web-app-web-app).
  3. Otherwise, combine the release name and chart name with a dash.

From deployment.yaml, lines 26-29:

env:
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}

range iterates over the .Values.env map. For each key-value pair, it produces an environment variable entry. Given the default values:

env:
APP_NAME: "Helm Demo App"
ENVIRONMENT: "development"

This renders to:

env:
- name: APP_NAME
value: "Helm Demo App"
- name: ENVIRONMENT
value: "development"

From deployment.yaml, lines 32-34:

{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}

with does two things at once. It acts as a conditional (the block is skipped if the value is empty/falsy) and it rebinds the context. Inside the with block, . refers to .Values.nodeSelector, not the root context. Since the default is {}, this entire block produces nothing, which is exactly what you want.


Helm injects several objects into every template. The four you see most often in this chart:

The merged result of values.yaml plus any overrides. This is where all user-configurable data lives.

image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"

Metadata about the current Helm release. Not the chart, not the app, the release (a specific installation of the chart).

  • .Release.Name is the name you pass to helm install. In this demo, it is web-app.
  • .Release.Service is always "Helm" in Helm 3.
  • .Release.Namespace is the target namespace.
  • .Release.Revision is the revision number (starts at 1, increments on each upgrade).

From _helpers.tpl, line 40:

app.kubernetes.io/managed-by: {{ .Release.Service }}

This labels every resource with app.kubernetes.io/managed-by: Helm, which helps identify Helm-managed resources.

Data from Chart.yaml. Used for metadata labels.

{{ .Chart.Name }} → simple-web-app
{{ .Chart.Version }} → 0.1.0
{{ .Chart.AppVersion }} → 1.0.0

From deployment.yaml, line 18:

- name: {{ .Chart.Name }}

This names the container after the chart. Simple and predictable.

Information about the Kubernetes cluster. Useful for conditional rendering based on cluster version or available API groups. This chart does not use it, but you would see it in charts that need to handle differences between Kubernetes versions:

{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
# Use networking.k8s.io/v1 Ingress
{{- else }}
# Fall back to extensions/v1beta1 Ingress
{{- end }}

Labels appear in multiple places across multiple template files. In this chart, both deployment.yaml and service.yaml need the same labels and the same fullname. Without helpers, you would copy-paste label blocks everywhere. Change a label, miss one file, and your selectors break.

_helpers.tpl centralizes these patterns into named templates. The file starts with _ so Helm knows it is not a manifest. It only defines templates for others to use.

define creates a named template. include calls it.

Definition in _helpers.tpl:

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

Usage in deployment.yaml, line 11:

matchLabels:
{{- include "simple-web-app.selectorLabels" . | nindent 6 }}

The . passed to include is the current context (the root object). Without it, the helper template would have no access to .Values, .Release, or .Chart.

Both call named templates, but they are not interchangeable.

  • template outputs directly. You cannot pipe its output.
  • include captures the output as a string. You can pipe it through nindent, trim, or other functions.

This chart always uses include because the output needs to be piped through nindent for proper indentation:

{{- include "simple-web-app.labels" . | nindent 4 }}

If you tried {{ template "simple-web-app.labels" . | nindent 4 }}, it would fail. template does not return a string, so there is nothing to pipe.

This chart defines five helpers that build on each other:

  1. simple-web-app.name produces the base name (chart name or override).
  2. simple-web-app.fullname produces the qualified name (release + chart name, with deduplication logic).
  3. simple-web-app.chart produces a chart identifier for the helm.sh/chart label.
  4. simple-web-app.selectorLabels produces the minimal label set for selectors. These must be immutable after creation.
  5. simple-web-app.labels produces the full label set, which includes selector labels plus metadata labels (chart version, app version, managed-by).

The hierarchy matters. Selector labels are a subset of common labels. The Deployment’s spec.selector.matchLabels must match spec.template.metadata.labels. By deriving both from the same helper, they are guaranteed to match.


6. Values Injection and Override Precedence

Section titled “6. Values Injection and Override Precedence”

Helm merges values from multiple sources. The precedence, from lowest to highest:

  1. values.yaml in the chart (the defaults).
  2. Parent chart’s values.yaml (if this chart is a sub-chart).
  3. -f / --values file passed at install/upgrade time.
  4. --set flags passed at install/upgrade time.

Higher precedence wins. So --set overrides a values file, which overrides the chart defaults.

Terminal window
helm install web-app chart/

Uses everything from values.yaml as-is. Two replicas, nginx:1.25.3-alpine, development environment.

Create a staging-values.yaml:

replicaCount: 4
env:
ENVIRONMENT: "staging"
Terminal window
helm install web-app chart/ -f staging-values.yaml

Helm deep-merges this with the defaults. replicaCount becomes 4. env.ENVIRONMENT becomes "staging". Everything else (image.repository, resources, service, env.APP_NAME) stays at its default value. The merge is recursive, so env.APP_NAME is preserved even though env.ENVIRONMENT was overridden.

Terminal window
helm upgrade web-app chart/ --set replicaCount=4 --set env.ENVIRONMENT=staging

Same result, but specified inline. --set is convenient for CI/CD pipelines and quick overrides. For complex changes, a values file is cleaner.

Dots navigate the hierarchy:

Terminal window
--set image.tag=1.26.0-alpine

This sets .Values.image.tag without affecting .Values.image.repository or .Values.image.pullPolicy.


A Helm release is a running instance of a chart. Every time you install a chart, you create a release. Every upgrade creates a new revision of that release.

Terminal window
helm install web-app chart/ --namespace helm-app --create-namespace

This does three things:

  1. Renders all templates with the merged values.
  2. Sends the resulting manifests to Kubernetes.
  3. Stores the release record (revision 1) as a Secret in the helm-app namespace.
Terminal window
helm upgrade web-app chart/ --namespace helm-app --set replicaCount=4

Helm re-renders the templates with the new values and applies the diff to the cluster. This creates revision 2. The previous revision is kept so you can roll back.

Important: helm upgrade replaces the entire values set with new defaults plus whatever you specify. If you installed with --set env.ENVIRONMENT=staging but upgrade without it, that override is lost. Use -f with a persistent values file to avoid this.

Terminal window
helm rollback web-app 1 --namespace helm-app

Rolls back to revision 1. This actually creates a new revision (revision 3) that has the same manifest content as revision 1. Helm does not rewrite history.

Terminal window
helm history web-app --namespace helm-app

Shows every revision with its status (deployed, superseded, rolled back), the timestamp, and the description.

Terminal window
helm uninstall web-app --namespace helm-app

Deletes all resources that belong to the release, plus the release record itself. The namespace is not deleted (Helm did not create it, even with --create-namespace… actually it did, but it does not delete it on uninstall). Clean up the namespace manually if needed:

Terminal window
kubectl delete namespace helm-app

Helm 3 stores release state as Kubernetes Secrets in the release’s namespace. Each revision gets its own Secret.

Terminal window
kubectl get secrets -n helm-app -l owner=helm

You will see Secrets named like sh.helm.release.v1.web-app.v1, sh.helm.release.v1.web-app.v2, and so on. Each one contains the rendered manifest, the values used, and the chart metadata, all base64-encoded and gzipped.

This is what makes rollback possible. Helm does not need to re-render the templates for a previous revision. It already has the exact manifest that was deployed.

This design has consequences:

  • Release state is namespace-scoped. Two teams can have releases with the same name in different namespaces without conflict.
  • No server-side component. Helm 2 used Tiller, a pod running in the cluster with broad RBAC permissions. Helm 3 removed it. Helm now runs entirely client-side and uses your kubeconfig credentials.
  • Secret size limits. Kubernetes Secrets are capped at 1 MB. Very large charts with many templates can hit this limit. In practice, it is rare.
  • History accumulates. Each upgrade adds a Secret. Set --history-max on upgrades to limit how many revisions Helm retains (default is 10).

When a template is not producing the YAML you expect, Helm gives you three tools to inspect what is happening.

Renders templates locally without talking to a cluster.

Terminal window
helm template web-app chart/

This outputs the rendered YAML to stdout. No cluster connection needed. No release is created. This is your fastest feedback loop when editing templates.

You can pass values overrides to test different configurations:

Terminal window
helm template web-app chart/ --set replicaCount=5

Renders templates and validates them against the cluster’s API, but does not apply anything.

Terminal window
helm install web-app chart/ --namespace helm-app --dry-run

This catches errors that helm template misses, like referencing an API version the cluster does not support or producing invalid resource specs. The difference is that --dry-run actually talks to the Kubernetes API server for validation.

Shows the manifest that was actually deployed for an existing release.

Terminal window
helm get manifest web-app --namespace helm-app

Use this to compare what you think was deployed versus what actually was. You can also use helm get values to see the values that were used:

Terminal window
helm get values web-app --namespace helm-app # user-supplied overrides only
helm get values web-app --namespace helm-app --all # merged with defaults

The with block in deployment.yaml is the standard pattern for optional configuration:

{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}

If nodeSelector is an empty map ({}), the entire block is omitted. If the user provides values, they are injected. This pattern repeats for affinity and tolerations in lines 32-40 of deployment.yaml.

This avoids littering manifests with empty fields. A Deployment with nodeSelector: {} is valid but noisy. Omitting it entirely is cleaner.

From _helpers.tpl, line 5:

{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}

default provides a fallback. If .Values.nameOverride is not set (empty string, nil), it falls back to .Chart.Name. This lets users customize the name without requiring them to.

From deployment.yaml, line 28:

value: {{ $value | quote }}

The quote function wraps the value in double quotes. This is essential for values that might be interpreted as non-strings by the YAML parser. Without quote, a value like "true" becomes a boolean and "1.0" becomes a float. Environment variables are always strings, so always quote them.

From deployment.yaml, line 31:

resources:
{{- toYaml .Values.resources | nindent 12 }}

toYaml converts a nested map to a YAML string. nindent 12 places it at the right indentation level. The result:

resources:
limits:
cpu: 50m
memory: 64Mi
requests:
cpu: 25m
memory: 32Mi

This pattern works for any structured value. You define the structure in values.yaml and inject it wholesale into the template. The chart author does not need to enumerate every possible field under resources, that is left to the user.

The labels in _helpers.tpl follow the Kubernetes recommended labels convention:

app.kubernetes.io/name: simple-web-app # What application is this
app.kubernetes.io/instance: web-app # Which instance (release name)
app.kubernetes.io/version: "1.0.0" # Application version
app.kubernetes.io/managed-by: Helm # What manages this resource
helm.sh/chart: simple-web-app-0.1.0 # Which chart (for Helm's use)

Selector labels use only name and instance because selectors are immutable after a Deployment is created. The metadata labels include version and chart info that may change across upgrades.

You see trunc 63 throughout _helpers.tpl. Kubernetes labels and names have a 63-character limit (DNS label spec, RFC 1123). When you combine release name + chart name, the result can exceed this. Truncating to 63 characters and trimming any trailing dash prevents validation errors.


Helm solves the problem of managing Kubernetes YAML across environments by adding three layers: templating (Go templates over YAML), packaging (the chart format), and lifecycle management (install, upgrade, rollback, history). The chart in this demo shows all the essential patterns: value injection, helper templates for DRY labels, conditional blocks for optional configuration, and safe defaults. Understanding these patterns gives you the foundation to read, modify, and build charts for production workloads.