Kubernetes Helm Chart Structure Reference
Kubernetes reference: Helm Chart structure
TL;DR
- Bottom line: A Helm chart is a versioned package of pre-configured Kubernetes resources defined by a required
Chart.yaml, avalues.yamlfor defaults, and Go-templated manifests intemplates/. - Key tool/command:
helm create mychartscaffolds the full directory structure with best-practice defaults. - Watch out for: CRD files in
crds/cannot be templated -- they are installed as plain YAML before any templates render. - Works with: Helm 3.x and 4.x, Kubernetes 1.19+, OCI registries (GA since Helm 3.8).
Constraints
- Chart.yaml MUST contain
apiVersion: v2,name, andversion-- all three are required fields - Chart names MUST be lowercase letters, numbers, and dashes only -- no uppercase, underscores, or dots
- Chart
versionMUST follow SemVer 2.0.0 -- Helm rejects non-compliant versions - CRDs in
crds/are plain YAML -- Go template directives are not processed - Never hardcode
namespace:in template metadata -- use{{ .Release.Namespace }}or let users pass--namespace - Hook-created resources are NOT tracked as part of the release -- they require explicit deletion policies
Quick Reference
| File / Directory | Required | Purpose | Templated |
|---|---|---|---|
Chart.yaml | Yes | Chart metadata: name, version, apiVersion, dependencies | No |
values.yaml | No (recommended) | Default configuration values for templates | No |
values.schema.json | No | JSON Schema validation for values | No |
templates/ | No | Go-templated Kubernetes manifests | Yes |
templates/_helpers.tpl | No | Named template definitions (partials) | Yes |
templates/NOTES.txt | No | Post-install usage instructions shown to user | Yes |
templates/tests/ | No | Test pod definitions run by helm test | Yes |
charts/ | No | Dependency chart archives (.tgz) or unpacked subdirectories | N/A |
crds/ | No | Custom Resource Definitions (installed before templates) | No |
Chart.lock | No | Locked dependency versions (auto-generated) | No |
.helmignore | No | File patterns to exclude from packaging | No |
LICENSE | No | Chart license file | No |
README.md | No | Chart documentation | No |
Chart.yaml Required and Common Fields
| Field | Required | Type | Description |
|---|---|---|---|
apiVersion | Yes | string | v2 for Helm 3/4 (v1 was Helm 2) |
name | Yes | string | Chart name (lowercase, dashes allowed) |
version | Yes | string | Chart version (SemVer 2.0.0) |
type | No | string | application (default, installable) or library (helpers only) |
appVersion | No | string | App version (informational, not used by Helm) |
kubeVersion | No | string | SemVer constraint for compatible K8s versions |
description | No | string | Single-sentence chart description |
dependencies | No | list | Subchart dependencies with name, version, repository |
keywords | No | list | Search keywords |
maintainers | No | list | Maintainer name, email, url |
icon | No | string | URL to SVG/PNG icon |
deprecated | No | bool | Mark chart as deprecated |
Decision Tree
START: What do you need?
├── Create a new chart from scratch?
│ ├── YES → Run `helm create mychart` (see Step 1)
│ └── NO ↓
├── Add dependencies/subcharts?
│ ├── YES → Add to Chart.yaml dependencies (see Step 3)
│ │ ├── OCI registry? → Use repository: "oci://registry/path"
│ │ └── Traditional repo? → Use repository: "https://charts.example.com"
│ └── NO ↓
├── Run pre/post-install jobs (DB migration, etc.)?
│ ├── YES → Use Helm hooks with annotations (see Hooks section)
│ └── NO ↓
├── Share reusable template helpers across charts?
│ ├── YES → Create a library chart (type: library)
│ └── NO ↓
├── Publish chart to a registry?
│ ├── OCI registry (recommended) → helm push mychart-1.0.0.tgz oci://registry/repo
│ └── Traditional repo → helm repo index + host index.yaml
└── DEFAULT → Use application chart (type: application)
Step-by-Step Guide
1. Scaffold a new chart
helm create generates a complete chart directory with best-practice defaults including a Deployment, Service, Ingress, HPA, and ServiceAccount template. [src1]
# Create a new chart named "myapp"
helm create myapp
# Directory structure created:
# myapp/
# ├── Chart.yaml
# ├── values.yaml
# ├── charts/
# ├── templates/
# │ ├── _helpers.tpl
# │ ├── deployment.yaml
# │ ├── hpa.yaml
# │ ├── ingress.yaml
# │ ├── service.yaml
# │ ├── serviceaccount.yaml
# │ ├── NOTES.txt
# │ └── tests/
# │ └── test-connection.yaml
# └── .helmignore
Verify: ls myapp/ → expected: Chart.yaml charts templates values.yaml
2. Define chart metadata in Chart.yaml
Every chart must declare its identity and version. The appVersion field is purely informational. [src1]
# myapp/Chart.yaml
apiVersion: v2
name: myapp
description: A production-ready web application chart
type: application
version: 0.1.0
appVersion: "1.16.0"
kubeVersion: ">=1.22.0-0"
maintainers:
- name: Platform Team
email: [email protected]
keywords:
- web
- api
- microservice
Verify: helm lint myapp/ → expected: 1 chart(s) linted, 0 chart(s) failed
3. Add dependencies
Dependencies are declared in Chart.yaml and resolved with helm dependency update. Both OCI and traditional repositories are supported. [src1] [src5]
# myapp/Chart.yaml (append to existing)
dependencies:
- name: postgresql
version: "15.5.x"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: postgresql.enabled
- name: redis
version: "19.x.x"
repository: "oci://registry-1.docker.io/bitnamicharts"
condition: redis.enabled
alias: cache
Verify: helm dependency list myapp/ → shows STATUS ok for all dependencies
4. Configure default values
Structure values with camelCase naming. Prefer flat over nested for simple configs. Quote all strings explicitly to avoid YAML type coercion. [src7]
# myapp/values.yaml
replicaCount: 3
image:
repository: myapp
pullPolicy: IfNotPresent
tag: ""
serviceAccount:
create: true
annotations: {}
name: ""
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: 256Mi
requests:
cpu: 100m
memory: 128Mi
Verify: helm template myapp myapp/ → renders all YAML with default values substituted
5. Write templates with built-in objects
Templates use Go template syntax with Sprig functions. The key built-in objects are .Values, .Release, .Chart, .Files, .Capabilities, and .Template. [src2]
# myapp/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:
- name: http
containerPort: 80
resources:
{{- toYaml .Values.resources | nindent 12 }}
Verify: helm template myapp myapp/ --debug → shows rendered manifest with no errors
6. Package and push to OCI registry
Helm 3.8+ and 4.x support OCI registries natively. The chart name and version are read from Chart.yaml automatically. [src5]
# Package the chart into a .tgz archive
helm package myapp/
# Login to OCI registry
helm registry login ghcr.io -u USERNAME
# Push to OCI registry
helm push myapp-0.1.0.tgz oci://ghcr.io/myorg/charts
# Install directly from OCI
helm install myapp oci://ghcr.io/myorg/charts/myapp --version 0.1.0
Verify: helm show chart oci://ghcr.io/myorg/charts/myapp --version 0.1.0 → displays Chart.yaml contents
Code Examples
Helm Hook: Pre-install database migration Job
# templates/db-migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "myapp.fullname" . }}-db-migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["./migrate", "--up"]
backoffLimit: 3
Conditional Ingress with TLS
# templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
values.schema.json for validation
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["replicaCount", "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"]
}
}
}
}
}
Anti-Patterns
Wrong: Hardcoding namespace in templates
# BAD -- prevents users from deploying to their chosen namespace
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
namespace: production
Correct: Let Helm manage namespace
# GOOD -- namespace set via helm install --namespace or Release.Namespace
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
Wrong: Using :latest tag in values.yaml
# BAD -- :latest is mutable, breaks rollbacks and reproducibility
image:
repository: myapp
tag: latest
Correct: Pin to specific version or use Chart.appVersion
# GOOD -- pinned version, defaults to Chart.appVersion if empty
image:
repository: myapp
tag: "" # defaults to .Chart.AppVersion via template logic
Wrong: Using arrays for user-configurable resources
# BAD -- arrays are hard to override with --set (index-based)
servers:
- name: foo
port: 80
- name: bar
port: 443
Correct: Use maps for merge-friendly overrides
# GOOD -- maps are easy to override: --set servers.foo.port=8080
servers:
foo:
port: 80
bar:
port: 443
Wrong: Not quoting string values in YAML
# BAD -- YAML type coercion: "yes" becomes boolean, "3.0" becomes float
enabled: yes
version: 3.0
Correct: Always quote strings explicitly
# GOOD -- explicit quoting prevents type coercion surprises
enabled: "yes"
version: "3.0"
Wrong: No resource limits in chart defaults
# BAD -- omitting resources risks OOMKills and noisy-neighbor issues
containers:
- name: myapp
image: myapp:1.0.0
Correct: Always set resource requests and limits
# GOOD -- set sane defaults that users can override via values.yaml
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 6 }}
Common Pitfalls
- Missing
includevstemplate:templatedoes not allow pipeline chaining (e.g.,| nindent). Always useincludefor named templates that need indentation. Fix: replace{{ template "foo" . }}with{{ include "foo" . | nindent N }}. [src2] - Whitespace in templates: Go templates insert whitespace literally. Fix: use
{{-(trim left) and-}}(trim right) to control whitespace. [src2] - Chart.lock out of sync: After editing dependencies in Chart.yaml,
Chart.lockis stale. Fix: runhelm dependency updateto regenerate. [src1] - Hook resources not cleaned up: Hook-created Jobs persist after completion. Fix: add
"helm.sh/hook-delete-policy": hook-succeededannotation. [src3] - SemVer
+in Kubernetes labels: K8s labels prohibit the+character. Fix: replace+with_using| replace "+" "_". [src4] - appVersion treated as constraint:
appVersionis informational only. Fix: do not rely on it for dependency resolution. [src1] - values.yaml type coercion: Bare
yes/nobecome booleans;3.0becomes a float. Fix: quote all string values. [src7] - Global values not propagated: Subcharts only receive their own scoped values. Fix: place shared config under
global:key. [src2]
Diagnostic Commands
# Lint chart for errors and best-practice warnings
helm lint mychart/
# Render templates locally without installing (dry run)
helm template myrelease mychart/ --debug
# Show computed values (merged defaults + overrides)
helm get values myrelease -n mynamespace
# Show all manifest content of an installed release
helm get manifest myrelease -n mynamespace
# List installed releases across all namespaces
helm list --all-namespaces
# Debug dependency resolution
helm dependency list mychart/
# Validate values against schema
helm install myrelease mychart/ --dry-run --debug
# Show chart metadata from OCI registry
helm show chart oci://ghcr.io/myorg/charts/mychart --version 1.0.0
# Diff upcoming upgrade (requires helm-diff plugin)
helm diff upgrade myrelease mychart/ -f custom-values.yaml
# Run chart test hooks
helm test myrelease -n mynamespace
Version History & Compatibility
| Version | Status | Key Changes | Migration Notes |
|---|---|---|---|
| Helm 4.x (Nov 2025) | Current | Server-side apply, WASM plugins, post-renderers as plugins, kstatus-based watching, reproducible builds | Chart.yaml apiVersion v2 unchanged; plugin configs may need updates |
| Helm 3.x (2019-2026) | Bug fixes until Jul 2026, security until Nov 2026 | Tiller removed, per-namespace releases, three-way merge, OCI GA (3.8+) | Most charts work on both 3.x and 4.x |
| Helm 2.x | EOL (Nov 2020) | Required Tiller with cluster-admin | Use helm 2to3 plugin; heritage labels immutable |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Packaging a multi-resource K8s app for distribution | Simple single-manifest deployment (1-2 YAML files) | Plain kubectl apply or Kustomize |
| Parameterized config across environments (dev/staging/prod) | All environments are identical | kubectl apply with a single manifest |
| Publishing reusable charts to a team or community | One-off internal deployment that never changes | Kustomize overlays |
| Managing complex dependency trees (app + database + cache) | Already using GitOps tool that handles deps | Native GitOps composition |
| Need lifecycle hooks for migrations, backups, or tests | Simple workloads with no pre/post-deploy actions | kubectl apply |
| Distributing charts via OCI registries alongside container images | Vendoring all manifests in a monorepo with no reuse | Directory of plain YAML |
Important Caveats
- Helm 4.0 introduced server-side apply and WASM plugins but kept Chart.yaml
apiVersion: v2-- existing charts work without changes, but post-renderer plugins may need migration - CRDs in
crds/are only installed onhelm install-- they are NOT updated onhelm upgrade. To update CRDs, apply them separately withkubectl apply - Library charts (type: library) cannot be installed directly -- they only provide helper templates consumed by application charts via dependencies
- OCI-based chart registries do not use
index.yaml--helm repo adddoes not work with OCI references; useoci://prefix directly - Hook resources are not tracked as part of the release --
helm uninstallwill NOT delete hook-created resources unless a delete-policy is set