Helm Charts and Directory Structure
A Helm chart is a deployable directory, not just a bag of YAML files. Its job is to turn a small, reviewed configuration surface into a complete set of Kubernetes manifests that can be installed, upgraded, rolled back, linted, packaged, and shared as one release unit. In this lesson, the outcome is practical: you should be able to read a chart directory, explain why each file is there, decide where a new template or value belongs, and predict what Helm will render before anything reaches the cluster.
What a Chart Contains
Helm treats a chart as a structured package with a few reserved paths. At minimum, a chart usually has Chart.yaml, values.yaml, and a templates/ directory. Chart.yaml is chart metadata: name, chart version, application version, type, maintainers, annotations, and dependencies. values.yaml is the default input document. Files under templates/ are Go template files that Helm renders into Kubernetes YAML. Optional paths add behavior: charts/ stores unpacked dependency charts, crds/ stores CustomResourceDefinition documents that Helm installs before templates, templates/_helpers.tpl usually stores reusable named templates, and templates/NOTES.txt renders post-install instructions instead of a Kubernetes object.
The directory shape matters because Helm assigns meaning to location. A YAML file under templates/ is rendered and sent to Kubernetes. The same file under the chart root is just an ordinary chart file. A document under crds/ is handled differently from a templated CRD under templates/. A dependency listed in Chart.yaml is not active until dependency charts are fetched or vendored. This location-based behavior is why chart reviews should inspect paths as carefully as YAML contents.
How Helm Renders Internally
Rendering starts when Helm loads the chart archive or directory, parses Chart.yaml, combines values, and evaluates templates. Values are merged in precedence order: chart defaults from values.yaml, parent chart values for dependencies, extra files passed with -f, and individual assignments from --set, --set-string, or related flags. Higher-precedence inputs replace lower-precedence inputs for the same key. Templates then receive a root object named . containing objects such as .Values, .Chart, .Release, .Capabilities, and .Files.
Helm uses Go templates plus the Sprig function library and Helm-specific functions. Expressions such as {{ .Values.image.repository }} read data. Pipelines such as {{ .Values.nameOverride | default .Chart.Name | trunc 63 | trimSuffix "-" }} transform data from left to right. Control structures such as if, with, and range choose or repeat output. Named templates declared with define are reusable snippets; chart authors commonly call them through include so the result can be piped into functions such as nindent.
After template evaluation, Helm splits multi-document YAML streams, sorts resources into an install order that respects common Kubernetes dependencies, and sends objects to the Kubernetes API for install or upgrade. Helm stores release state as Kubernetes Secrets or ConfigMaps in the release namespace, depending on storage driver. That release record is what makes helm history, helm rollback, and three-way upgrade calculations possible. A chart directory is therefore both source code and release-engineering input: small naming choices in helpers and values become part of the upgrade identity Kubernetes uses later.
Syntax and File Anatomy
The following minimal chart shows the core files and how they work together. The chart name and helper output become Kubernetes object names. The values file defines the stable configuration keys users are expected to override.
catalogue/
Chart.yaml
values.yaml
templates/
_helpers.tpl
deployment.yaml
service.yaml
apiVersion: v2
name: catalogue
description: A small catalogue service chart
type: application
version: 0.1.0
appVersion: "1.0"
replicaCount: 2
image:
repository: ghcr.io/example/catalogue
tag: "1.0"
pullPolicy: IfNotPresent
service:
port: 8080
{{- define "catalogue.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "catalogue.fullname" . }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: catalogue
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.port }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "catalogue.fullname" . }}
spec:
selector:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
If this chart is rendered with release name demo, the Deployment and Service names become demo-catalogue. The selector labels match the Pod template labels exactly. That exact match is not cosmetic; a Deployment selector is immutable after creation, and a Service with a mismatched selector silently sends traffic to no Pods.
Worked Example 1: Render Before Install
The first review step is local rendering. No cluster is required for this example, which makes it a good check in a pull request.
helm template demo ./catalogue
The deterministic parts of the output include a Deployment named demo-catalogue, a Service named demo-catalogue, two replicas, and image ghcr.io/example/catalogue:1.0. If the rendered YAML contains {{ or }}, a template expression escaped rendering or sits in a file Helm did not process. If the Service selector differs from the Pod labels, the chart may install successfully but traffic will not reach the workload.
Worked Example 2: Override Values Without Forking
A chart becomes reusable when environment-specific changes are expressed as values rather than copied templates. A staging override can change replica count and image tag while leaving names, labels, and service wiring consistent.
replicaCount: 1
image:
tag: "1.1-rc1"
helm template demo ./catalogue -f staging-values.yaml
The expected rendered differences are narrow: spec.replicas changes from 2 to 1, and the container image tag changes from 1.0 to 1.1-rc1. The object names and selectors do not change. This is the main design benefit of a chart directory: the template author controls the structural contract, while the deployer changes declared inputs.
Worked Example 3: Add an Optional Ingress
Optional resources belong behind explicit values. Put the resource in its own template and make the default disabled unless most installs need it.
ingress:
enabled: false
className: nginx
host: catalogue.example.test
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "catalogue.fullname" . }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "catalogue.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}
helm template demo ./catalogue --set ingress.enabled=true --set ingress.host=shop.example.test
When disabled, this file renders no Kubernetes object. When enabled, Helm emits one Ingress pointing to the same Service name and port as the rest of the chart. The trade-off is clear: optional templates reduce chart forks, but too many switches can turn values.yaml into an undocumented API. Group related keys, provide comments in the values file, and keep defaults boring.
Design Choices and Trade-offs
Put repeated names and labels in _helpers.tpl when more than one template needs them. This avoids drift between Deployments, Services, ServiceAccounts, and role bindings. The cost is indirection: reviewers must know where names come from. Keep helper names specific and stable, because changing a helper that feeds object names can cause replacement instead of upgrade.
Use subcharts when a component has its own lifecycle and values. For example, a web application chart might depend on a Redis chart for local development. In production, teams often prefer an external managed Redis and disable the dependency. Subcharts improve reuse, but they add values nesting, dependency updates, and ownership questions. If the component is inseparable from the application, one chart with multiple templates is often easier to operate.
Keep CRDs in crds/ only when the chart is responsible for initial CRD installation. Helm installs those files before templates, but it does not upgrade or delete CRDs like ordinary resources. That protects custom resources from accidental data loss, but it also means CRD schema changes need a separate operational plan. Application charts that merely create custom resources should usually document the CRD prerequisite instead of bundling platform-level APIs casually.
Failure Modes and Troubleshooting
A common symptom is YAML parse error during helm template or helm install. The cause is often indentation around conditionals or inserted maps. Diagnose by running helm template --debug and inspecting the rendered area above the reported line. Correct it by using toYaml with nindent for nested values, and by placing template control lines where they do not leave dangling keys.
Another failure is a successful install with no traffic. The Service exists, Pods are Ready, but requests time out. The likely cause is selector mismatch between service.yaml and deployment.yaml. Diagnose with kubectl get endpoints or kubectl get endpointslice; an empty endpoint set confirms the Service selected no Pods. Correct the shared labels through helpers, render again, and upgrade the release.
A third failure appears during upgrade: Kubernetes rejects a Deployment because spec.selector is immutable. This usually happens when a chart author changed selector labels after the first release. Diagnose by comparing helm get manifest RELEASE with the new helm template output. Correct by preserving selector labels, or by planning a deliberate replacement with downtime or a parallel release name. Do not hide this behind --force without understanding that it may delete and recreate resources.
Security, Performance, and Reliability
Chart structure affects security because it defines what users can change. Do not expose arbitrary labels, annotations, host paths, capabilities, or service account names unless the chart intentionally supports them. Values are inputs to code generation; treat them as a chart API. Use required for values that must be supplied, quote for ambiguous strings, and JSON schema validation in values.schema.json when a chart has many users.
Performance concerns are usually operational rather than rendering speed. A default replica count, probe period, resource request, or autoscaling option can affect cluster capacity. Reliability depends on stable names, stable selectors, probes, PodDisruptionBudgets where appropriate, and upgrade behavior that does not replace stateful resources accidentally. A clean directory layout makes these concerns visible during review.
Hands-on Lab
Prerequisites: Helm, kubectl, and access to a disposable namespace in a Kubernetes cluster. If no cluster is available, complete the render and lint steps locally and skip the install commands.
- Create the
catalogue/chart directory with the files shown in the earlier examples. - Run
helm lint ./catalogue. Fix any chart metadata, template, or values errors before continuing. - Render locally with
helm template demo ./catalogue > rendered.yaml. Verify that the Deployment and Service are nameddemo-catalogue. - Create a namespace with
kubectl create namespace helm-structure-lab. - Install with
helm install demo ./catalogue -n helm-structure-lab. - Verify with
helm status demo -n helm-structure-lab,kubectl get deploy,svc -n helm-structure-lab, andkubectl get endpoints -n helm-structure-lab demo-catalogue. - Upgrade with
helm upgrade demo ./catalogue -n helm-structure-lab --set replicaCount=1, then confirm the Deployment reports one desired replica. - Clean up with
helm uninstall demo -n helm-structure-labandkubectl delete namespace helm-structure-lab.
The verification target is not merely that Helm exits successfully. You should confirm that the release record exists, the rendered names are predictable, the Service has endpoints, and the upgrade changed only the intended field.
Assessment Exercises
- A chart installs successfully, but its Service has no endpoints. Which files would you compare, what exact labels would you inspect, and how would you prevent the drift from returning?
- You need different image tags for staging and production. Explain why a values override is preferable to copying
deployment.yamlinto two chart directories. - A teammate proposes templating CRDs under
templates/. What upgrade and deletion risks should you discuss before accepting that design? - Given a helper that builds names from
.Release.Nameand.Chart.Name, what could happen if you change it after releases already exist? - Add an optional NetworkPolicy to the chart. Which value keys would you expose, and which parts should remain fixed to protect the chart contract?
Summary
Helm chart structure is the packaging contract between application authors, release engineers, and Kubernetes. Chart.yaml describes the package, values.yaml defines its input API, templates/ generates Kubernetes objects, helpers centralize shared naming, dependencies compose charts, and crds/ has special lifecycle behavior. Good charts keep names and selectors stable, expose deliberate values, render predictably, and make install, upgrade, rollback, and troubleshooting understandable from the directory tree itself.
