Helm & Kubernetes Packaging

Chart Anatomy

18 min Lesson 3 of 28

Chart Anatomy

Every Helm chart is a directory with a strict layout. Learn that layout once and you can read, debug, and author any chart you encounter — whether it is the official Redis chart from Bitnami, an internal platform chart at your company, or the chart you are about to write from scratch. This lesson walks through every file and directory in detail, explains why each one exists, and shows the production patterns that separate maintainable charts from fragile ones.

The Standard Directory Tree

Running helm create myapp scaffolds this structure. It is not arbitrary — Helm's template engine, dependency resolver, and packaging commands all expect exactly this layout.

myapp/ ├── Chart.yaml # Chart metadata (required) ├── values.yaml # Default configuration values (required) ├── values.schema.json # JSON Schema for values validation (optional, strongly recommended) ├── charts/ # Packaged sub-chart dependencies (.tgz files) ├── crds/ # CustomResourceDefinition manifests (installed before templates) ├── templates/ # Kubernetes manifest templates (required) │ ├── NOTES.txt # Post-install usage instructions printed to the terminal │ ├── _helpers.tpl # Named templates and helper functions (NOT rendered as a manifest) │ ├── deployment.yaml │ ├── service.yaml │ ├── ingress.yaml │ ├── serviceaccount.yaml │ ├── hpa.yaml │ └── tests/ │ └── test-connection.yaml # Helm test Pod (run with: helm test <release>) └── .helmignore # Files excluded from the packaged .tgz (like .gitignore)
Helm Chart Directory Structure myapp/ Chart root Chart.yaml name · version · type values.yaml Default configuration templates/ K8s manifests + helpers charts/ Dependency sub-charts crds/ CRDs (pre-install) _helpers.tpl deployment.yaml service.yaml NOTES.txt
Helm chart directory structure — every file has a defined role in the packaging contract.

Chart.yaml — The Identity Document

Chart.yaml is mandatory. Helm reads it before doing anything else. A minimal production Chart.yaml looks like this:

# Chart.yaml apiVersion: v2 # v2 = Helm 3 (v1 = legacy Helm 2; never use v1 for new charts) name: myapp description: A production-ready web application chart type: application # "application" (deployable) vs. "library" (templates-only, no K8s objects) version: 1.4.2 # CHART version (SemVer) — bump when the chart logic changes appVersion: "2.7.0" # APPLICATION version — the Docker image tag you are shipping # Optional but used by repositories and tooling: keywords: - web - api home: https://github.com/acme/myapp sources: - https://github.com/acme/myapp maintainers: - name: Platform Engineering email: platform@acme.com dependencies: - name: postgresql version: "15.x.x" repository: "https://charts.bitnami.com/bitnami" condition: postgresql.enabled # controlled by values.yaml
version vs. appVersion: These two fields confuse almost every engineer the first time. version is the chart version — it changes when you fix a bug in a template or add a new Helm feature. appVersion is the application version — it is informational only; Helm does not use it to pull images. Your deployment.yaml template reads the image tag from values.yaml, not from appVersion. Many teams keep them in sync anyway for clarity, but they are independent.

values.yaml — The Single Source of Truth for Configuration

values.yaml defines the default configuration that every template in templates/ can reference. It is the public API of your chart. Anyone who installs your chart will override parts of this file via --set or their own values.yaml. Design it thoughtfully — changing key names is a breaking change for your users.

# values.yaml — production-grade example replicaCount: 3 image: repository: gcr.io/acme/myapp tag: "" # intentionally empty — overridden by CI via --set image.tag=$SHA pullPolicy: IfNotPresent imagePullSecrets: - name: gcr-credentials serviceAccount: create: true name: "" # if empty, the _helpers.tpl macro generates a name from the release service: type: ClusterIP port: 80 ingress: enabled: false className: "nginx" annotations: {} hosts: - host: myapp.example.com paths: - path: / pathType: Prefix tls: [] resources: limits: cpu: 500m memory: 512Mi requests: cpu: 100m memory: 128Mi autoscaling: enabled: false minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70 nodeSelector: {} tolerations: [] affinity: {} postgresql: enabled: true # controls the dependency (matches condition: in Chart.yaml) auth: database: myapp username: myapp existingSecret: "myapp-db-secret"
Production practice — never hardcode secrets in values.yaml. Use existingSecret patterns (as shown above) or external secrets operators (External Secrets Operator, Vault Agent). The values.yaml file is committed to git and often visible in CI logs. Credentials in it will eventually leak.

The templates/ Directory — Where Manifests Are Born

Every .yaml file in templates/ is processed by Helm's Go templating engine and rendered into a plain Kubernetes manifest. Files beginning with _ (like _helpers.tpl) are never rendered as manifests — they exist solely to define reusable named templates.

Inside a template, the top-level objects you work with are:

  • .Values — everything from values.yaml merged with any --set overrides
  • .Release — metadata about the current release: .Release.Name, .Release.Namespace, .Release.IsInstall, .Release.IsUpgrade
  • .Chart — fields from Chart.yaml: .Chart.Name, .Chart.Version, .Chart.AppVersion
  • .Capabilities — cluster information: .Capabilities.KubeVersion.Major, .Capabilities.APIVersions.Has "autoscaling/v2"
  • .Files — access to non-template files bundled in the chart (e.g., config files via .Files.Get "config/nginx.conf")

_helpers.tpl — The Chart's Standard Library

_helpers.tpl is where you define named templates (macros) that are called from your manifest templates. Every chart generated by helm create ships with a standard set of helpers. Here is what they do and why they matter:

{{/* _helpers.tpl — standard helpers shipped by "helm create" */}} {{/* Expand the name of the chart (truncated to 63 chars — K8s label limit) */}} {{- define "myapp.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Fullname: Release.Name + chart name, or just Release.Name if it already contains the chart name. This is the value stamped onto every Deployment name, Service name, and label. */}} {{- define "myapp.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 }} {{/* Chart label: "myapp-1.4.2" — used in the helm.sh/chart label */}} {{- define "myapp.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Standard selector labels — MUST be stable across upgrades (never include version!) */}} {{- define "myapp.selectorLabels" -}} app.kubernetes.io/name: {{ include "myapp.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Common labels — applied to all objects including the chart version label */}} {{- define "myapp.labels" -}} helm.sh/chart: {{ include "myapp.chart" . }} {{ include "myapp.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }}
Production pitfall — unstable selector labels. The spec.selector in a Deployment is immutable after creation. If you include a chart version or image tag in your selector labels and then upgrade, Kubernetes will refuse the update with an error like field is immutable. The selectorLabels helper deliberately includes only app.kubernetes.io/name and app.kubernetes.io/instance — values that never change across releases. The labels helper (used in metadata annotations and Pod template labels) can safely include the version.

Putting It Together — How a Template Consumes Helpers

A real deployment.yaml calls these helpers with include and passes the root context .. This is what big-tech internal platform teams actually ship:

# templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "myapp.fullname" . }} labels: {{- include "myapp.labels" . | nindent 4 }} spec: {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.replicaCount }} {{- end }} selector: matchLabels: {{- include "myapp.selectorLabels" . | nindent 6 }} template: metadata: labels: {{- include "myapp.selectorLabels" . | nindent 8 }} spec: serviceAccountName: {{ include "myapp.serviceAccountName" . }} containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} ports: - name: http containerPort: 8080 protocol: TCP resources: {{- toYaml .Values.resources | nindent 12 }}
nindent vs. indent: nindent N adds a leading newline before indenting — essential when you pipe a multi-line block into a YAML key. Without it, the first line of the block lands on the same line as the key and breaks YAML parsing. This is the most common formatting bug in beginner templates.

values.schema.json — Validation at Install Time

As your chart grows, callers will make typos — setting replicas instead of replicaCount, or passing a string where a number is expected. Add a JSON Schema file and Helm validates every helm install or helm upgrade call before touching the cluster. At big-tech scale this prevents entire classes of misconfiguration incidents.

# values.schema.json (excerpt) { "$schema": "https://json-schema.org/draft-07/schema#", "type": "object", "required": ["image"], "properties": { "replicaCount": { "type": "integer", "minimum": 1 }, "image": { "type": "object", "required": ["repository"], "properties": { "repository": { "type": "string" }, "tag": { "type": "string" }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } } }, "resources": { "type": "object" } } }

With this schema in place, helm install myapp . --set replicaCount=abc fails immediately with a clear validation error rather than deploying a broken Deployment that Kubernetes will silently ignore or reject minutes later.

The Full Picture at a Glance

When you run helm install myrelease ./myapp -f prod-values.yaml, Helm executes this sequence: load Chart.yaml (identity + dependency list) → resolve sub-charts in charts/ → merge values.yaml with your override file → validate against values.schema.json → render every templates/*.yaml through the Go template engine → apply CRDs from crds/ first → then apply all rendered manifests to the cluster in dependency order. Understanding this sequence is the difference between guessing why a chart fails and knowing exactly where to look.