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.
1. Why Helm Exists
Section titled “1. Why Helm Exists”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:
- Duplication. Copy-pasting manifests across environments leads to drift. One environment gets updated, another does not.
- No packaging. There is no standard way to bundle related resources (Deployment + Service + ConfigMap) into a single deployable unit.
- No lifecycle management. Raw
kubectl applyhas 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.
2. Chart Structure
Section titled “2. Chart Structure”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 ServiceThere 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.
Chart.yaml
Section titled “Chart.yaml”This is the chart’s identity card. From chart/Chart.yaml:
apiVersion: v2name: simple-web-appdescription: A simple web application Helm chart for ArgoCD demotype: applicationversion: 0.1.0appVersion: "1.0.0"Key fields:
apiVersion: v2signals this is a Helm 3 chart. Helm 2 usedv1. The difference matters because Helm 3 removed Tiller (the server-side component) and changed how releases are stored.namebecomes the chart’s identifier. It shows up in template objects like.Chart.Name.versionis the chart version. This is the version of the packaging, not the application. Bump this when you change templates, defaults, or chart structure.appVersionis 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.tplline 38) and inhelm listoutput.type: applicationmeans this chart deploys resources. The alternative islibrary, which provides only helper templates for other charts to import.
values.yaml
Section titled “values.yaml”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.
templates/
Section titled “templates/”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.
3. Go Template Syntax
Section titled “3. Go Template Syntax”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.
The Basics: {{ }}
Section titled “The Basics: {{ }}”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: 2Whitespace Control: {{- and -}}
Section titled “Whitespace Control: {{- and -}}”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
Section titled “Pipelines”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:
default .Chart.Name .Values.nameOverridereturns.Values.nameOverrideif set, otherwise.Chart.Name.| trunc 63truncates to 63 characters (Kubernetes label length limit).| 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.
Conditionals: if, else, end
Section titled “Conditionals: if, else, end”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:
- If the user provides
fullnameOverride, use it directly. - If the release name already contains the chart name, use just the release name (avoids
web-app-web-app). - Otherwise, combine the release name and chart name with a dash.
Range Loops
Section titled “Range Loops”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"The with Block
Section titled “The with Block”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.
4. Built-in Objects
Section titled “4. Built-in Objects”Helm injects several objects into every template. The four you see most often in this chart:
.Values
Section titled “.Values”The merged result of values.yaml plus any overrides. This is where all user-configurable data lives.
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}".Release
Section titled “.Release”Metadata about the current Helm release. Not the chart, not the app, the release (a specific installation of the chart).
.Release.Nameis the name you pass tohelm install. In this demo, it isweb-app..Release.Serviceis always"Helm"in Helm 3..Release.Namespaceis the target namespace..Release.Revisionis 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.
.Chart
Section titled “.Chart”Data from Chart.yaml. Used for metadata labels.
{{ .Chart.Name }} → simple-web-app{{ .Chart.Version }} → 0.1.0{{ .Chart.AppVersion }} → 1.0.0From deployment.yaml, line 18:
- name: {{ .Chart.Name }}This names the container after the chart. Simple and predictable.
.Capabilities
Section titled “.Capabilities”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 }}5. Helper Templates (_helpers.tpl)
Section titled “5. Helper Templates (_helpers.tpl)”Why They Exist
Section titled “Why They Exist”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 and include
Section titled “define and include”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.
include vs template
Section titled “include vs template”Both call named templates, but they are not interchangeable.
templateoutputs directly. You cannot pipe its output.includecaptures the output as a string. You can pipe it throughnindent,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.
The Helper Chain
Section titled “The Helper Chain”This chart defines five helpers that build on each other:
simple-web-app.nameproduces the base name (chart name or override).simple-web-app.fullnameproduces the qualified name (release + chart name, with deduplication logic).simple-web-app.chartproduces a chart identifier for thehelm.sh/chartlabel.simple-web-app.selectorLabelsproduces the minimal label set for selectors. These must be immutable after creation.simple-web-app.labelsproduces 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:
values.yamlin the chart (the defaults).- Parent chart’s
values.yaml(if this chart is a sub-chart). -f/--valuesfile passed at install/upgrade time.--setflags passed at install/upgrade time.
Higher precedence wins. So --set overrides a values file, which overrides the chart defaults.
Example: Default Values
Section titled “Example: Default Values”helm install web-app chart/Uses everything from values.yaml as-is. Two replicas, nginx:1.25.3-alpine, development environment.
Example: Values File Override
Section titled “Example: Values File Override”Create a staging-values.yaml:
replicaCount: 4env: ENVIRONMENT: "staging"helm install web-app chart/ -f staging-values.yamlHelm 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.
Example: --set Override
Section titled “Example: --set Override”helm upgrade web-app chart/ --set replicaCount=4 --set env.ENVIRONMENT=stagingSame result, but specified inline. --set is convenient for CI/CD pipelines and quick overrides. For complex changes, a values file is cleaner.
Nested Keys with --set
Section titled “Nested Keys with --set”Dots navigate the hierarchy:
--set image.tag=1.26.0-alpineThis sets .Values.image.tag without affecting .Values.image.repository or .Values.image.pullPolicy.
7. Release Lifecycle
Section titled “7. Release Lifecycle”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.
Install
Section titled “Install”helm install web-app chart/ --namespace helm-app --create-namespaceThis does three things:
- Renders all templates with the merged values.
- Sends the resulting manifests to Kubernetes.
- Stores the release record (revision 1) as a Secret in the
helm-appnamespace.
Upgrade
Section titled “Upgrade”helm upgrade web-app chart/ --namespace helm-app --set replicaCount=4Helm 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.
Rollback
Section titled “Rollback”helm rollback web-app 1 --namespace helm-appRolls 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.
History
Section titled “History”helm history web-app --namespace helm-appShows every revision with its status (deployed, superseded, rolled back), the timestamp, and the description.
Uninstall
Section titled “Uninstall”helm uninstall web-app --namespace helm-appDeletes 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:
kubectl delete namespace helm-app8. How Helm Tracks State
Section titled “8. How Helm Tracks State”Helm 3 stores release state as Kubernetes Secrets in the release’s namespace. Each revision gets its own Secret.
kubectl get secrets -n helm-app -l owner=helmYou 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-maxon upgrades to limit how many revisions Helm retains (default is 10).
9. Template Rendering and Debugging
Section titled “9. Template Rendering and Debugging”When a template is not producing the YAML you expect, Helm gives you three tools to inspect what is happening.
helm template
Section titled “helm template”Renders templates locally without talking to a cluster.
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:
helm template web-app chart/ --set replicaCount=5helm install --dry-run
Section titled “helm install --dry-run”Renders templates and validates them against the cluster’s API, but does not apply anything.
helm install web-app chart/ --namespace helm-app --dry-runThis 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.
helm get manifest
Section titled “helm get manifest”Shows the manifest that was actually deployed for an existing release.
helm get manifest web-app --namespace helm-appUse 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:
helm get values web-app --namespace helm-app # user-supplied overrides onlyhelm get values web-app --namespace helm-app --all # merged with defaults10. Common Patterns
Section titled “10. Common Patterns”Conditionals for Optional Resources
Section titled “Conditionals for Optional Resources”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.
Safe Defaults with default
Section titled “Safe Defaults with default”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.
Quoting Values
Section titled “Quoting Values”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.
Resource Limits with toYaml
Section titled “Resource Limits with toYaml”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: 32MiThis 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.
Kubernetes Label Conventions
Section titled “Kubernetes Label Conventions”The labels in _helpers.tpl follow the Kubernetes recommended labels convention:
app.kubernetes.io/name: simple-web-app # What application is thisapp.kubernetes.io/instance: web-app # Which instance (release name)app.kubernetes.io/version: "1.0.0" # Application versionapp.kubernetes.io/managed-by: Helm # What manages this resourcehelm.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.
The 63-Character Truncation
Section titled “The 63-Character Truncation”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.
Summary
Section titled “Summary”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.