Kubernetes Helm Chart Structure Reference

Type: Software Reference Confidence: 0.94 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

File / DirectoryRequiredPurposeTemplated
Chart.yamlYesChart metadata: name, version, apiVersion, dependenciesNo
values.yamlNo (recommended)Default configuration values for templatesNo
values.schema.jsonNoJSON Schema validation for valuesNo
templates/NoGo-templated Kubernetes manifestsYes
templates/_helpers.tplNoNamed template definitions (partials)Yes
templates/NOTES.txtNoPost-install usage instructions shown to userYes
templates/tests/NoTest pod definitions run by helm testYes
charts/NoDependency chart archives (.tgz) or unpacked subdirectoriesN/A
crds/NoCustom Resource Definitions (installed before templates)No
Chart.lockNoLocked dependency versions (auto-generated)No
.helmignoreNoFile patterns to exclude from packagingNo
LICENSENoChart license fileNo
README.mdNoChart documentationNo

Chart.yaml Required and Common Fields

FieldRequiredTypeDescription
apiVersionYesstringv2 for Helm 3/4 (v1 was Helm 2)
nameYesstringChart name (lowercase, dashes allowed)
versionYesstringChart version (SemVer 2.0.0)
typeNostringapplication (default, installable) or library (helpers only)
appVersionNostringApp version (informational, not used by Helm)
kubeVersionNostringSemVer constraint for compatible K8s versions
descriptionNostringSingle-sentence chart description
dependenciesNolistSubchart dependencies with name, version, repository
keywordsNolistSearch keywords
maintainersNolistMaintainer name, email, url
iconNostringURL to SVG/PNG icon
deprecatedNoboolMark 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

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

VersionStatusKey ChangesMigration Notes
Helm 4.x (Nov 2025)CurrentServer-side apply, WASM plugins, post-renderers as plugins, kstatus-based watching, reproducible buildsChart.yaml apiVersion v2 unchanged; plugin configs may need updates
Helm 3.x (2019-2026)Bug fixes until Jul 2026, security until Nov 2026Tiller removed, per-namespace releases, three-way merge, OCI GA (3.8+)Most charts work on both 3.x and 4.x
Helm 2.xEOL (Nov 2020)Required Tiller with cluster-adminUse helm 2to3 plugin; heritage labels immutable

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Packaging a multi-resource K8s app for distributionSimple single-manifest deployment (1-2 YAML files)Plain kubectl apply or Kustomize
Parameterized config across environments (dev/staging/prod)All environments are identicalkubectl apply with a single manifest
Publishing reusable charts to a team or communityOne-off internal deployment that never changesKustomize overlays
Managing complex dependency trees (app + database + cache)Already using GitOps tool that handles depsNative GitOps composition
Need lifecycle hooks for migrations, backups, or testsSimple workloads with no pre/post-deploy actionskubectl apply
Distributing charts via OCI registries alongside container imagesVendoring all manifests in a monorepo with no reuseDirectory of plain YAML

Important Caveats

Related Units