Skip to main content

Helm Chart Reference

Chart Structure

Minimal Production Chart

mychart/
├── Chart.yaml
├── values.yaml
├── .helmignore
└── templates/
├── _helpers.tpl
├── deployment.yaml
├── service.yaml
├── serviceaccount.yaml
├── NOTES.txt
└── tests/
└── test-connection.yaml

Full Production Chart

mychart/
├── Chart.yaml
├── values.yaml
├── values.schema.json
├── .helmignore
├── templates/
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── serviceaccount.yaml
│ ├── hpa.yaml
│ ├── pdb.yaml
│ ├── networkpolicy.yaml
│ ├── configmap.yaml
│ ├── NOTES.txt
│ └── tests/
│ └── test-connection.yaml
└── charts/

_helpers.tpl — Standard Helpers

Copy and adapt for every new chart. These four helpers are the minimum.

{{- define "mychart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "mychart.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 }}

{{- define "mychart.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels — applied to all resources.
*/}}
{{- define "mychart.labels" -}}
helm.sh/chart: {{ include "mychart.chart" . }}
{{ include "mychart.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels — used in matchLabels. MUST be immutable after release creation.
Never add app.kubernetes.io/version here.
*/}}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{- define "mychart.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "mychart.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

Why selectorLabels is separate from labels: matchLabels in a Deployment selector is immutable after the first helm install. If app.kubernetes.io/version is in selectorLabels, every chart upgrade changes the selector and causes Helm to fail with an immutable field error. Keep version only in labels.


Deployment — Security-Hardened Baseline

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "mychart.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "mychart.serviceAccountName" . }}
automountServiceAccountToken: false
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}

Conditional Resource Templates

Ingress

{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.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 "mychart.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}

HPA

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "mychart.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}

PodDisruptionBudget

{{- if .Values.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
{{- if .Values.pdb.minAvailable }}
minAvailable: {{ .Values.pdb.minAvailable }}
{{- end }}
{{- if .Values.pdb.maxUnavailable }}
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
{{- end }}

NetworkPolicy

{{- if .Values.networkPolicy.enabled }}
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
policyTypes:
- Ingress
- Egress
ingress:
{{- toYaml .Values.networkPolicy.ingress | nindent 4 }}
egress:
{{- toYaml .Values.networkPolicy.egress | nindent 4 }}
{{- end }}

Standard values.yaml

# -- Number of pod replicas (ignored when autoscaling.enabled is true)
replicaCount: 1

nameOverride: ""
fullnameOverride: ""

image:
# -- Container image repository
repository: nginx
# -- Image pull policy
pullPolicy: IfNotPresent
# -- Image tag — defaults to .Chart.AppVersion when empty
tag: ""

imagePullSecrets: []

serviceAccount:
# -- Create a dedicated ServiceAccount
create: true
annotations: {}
name: ""
# -- Do not mount the token unless the pod needs K8s API access
automount: false

podAnnotations: {}
podLabels: {}

# -- Pod-level security context
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault

# -- Container-level security context
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL

service:
type: ClusterIP
port: 80

ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []

# -- Container resource requests and limits.
# CPU limit is intentionally omitted — setting it causes CFS throttling for bursty workloads.
# Set a CPU limit only if you need hard multi-tenant isolation and accept the throttling trade-off.
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi

livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 15
periodSeconds: 20

readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 5
periodSeconds: 10

autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80

pdb:
enabled: false
minAvailable: 1

networkPolicy:
enabled: false
ingress: []
egress: []

nodeSelector: {}
tolerations: []
affinity: {}
volumes: []
volumeMounts: []

values.schema.json — Minimal Type Safety

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["replicaCount", "image"],
"properties": {
"replicaCount": {
"type": "integer",
"minimum": 1
},
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"service": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["ClusterIP", "NodePort", "LoadBalancer"]
},
"port": { "type": "integer", "minimum": 1, "maximum": 65535 }
}
}
}
}

Schema validation runs automatically on helm install and helm lint. Bad values fail fast before any template rendering.


Dependency Management

Chart.yaml with Dependencies

dependencies:
- name: postgresql
version: ~15.5.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: redis
version: ~19.0.0
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled

Rules:

  • Use ~X.Y.Z (patch-level float) — never an exact pin or a major float
  • Always set condition: so subcharts can be disabled without editing Chart.yaml
  • Use alias: for multiple instances of the same chart
helm dependency update mychart/ # downloads and locks to charts/
helm dependency list mychart/ # shows current status and locked versions

Overriding Subchart Values

# values.yaml — override under the dependency name key
postgresql:
enabled: true
auth:
database: myapp
username: myapp
primary:
resources:
requests:
cpu: 250m
memory: 256Mi

Lint and Validation Pipeline

# 1. Basic lint — catches YAML errors and missing required fields
helm lint mychart/

# 2. Strict lint — promotes warnings to errors (use in CI)
helm lint mychart/ --strict

# 3. Render templates — review output before deploying
helm template myrelease mychart/ --debug

# 4. Render with overrides
helm template myrelease mychart/ -f values-production.yaml

# 5. Validate rendered manifests against K8s schemas
helm template myrelease mychart/ | kubeconform -strict -summary

# 6. Security scan on rendered output
helm template myrelease mychart/ | checkov -d - --framework kubernetes

# 7. Run in-cluster tests after install
helm test myrelease

Fail the CI pipeline on any helm lint --strict warning. Never merge a chart that fails lint.


Helm Test Template

apiVersion: v1
kind: Pod
metadata:
name: "{{ include "mychart.fullname" . }}-test-connection"
labels:
{{- include "mychart.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox:1.36
command: ['wget']
args: ['-qO-', '{{ include "mychart.fullname" . }}:{{ .Values.service.port }}/healthz']
restartPolicy: Never

NOTES.txt Pattern

1. Get the application URL:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mychart.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "ClusterIP" .Values.service.type }}
kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "mychart.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
echo "Visit http://127.0.0.1:{{ .Values.service.port }}"
{{- end }}

Common Troubleshooting

SymptomCauseFix
helm upgrade fails with immutable field errorapp.kubernetes.io/version in selectorLabelsRemove version from selectorLabels; it belongs in labels only
Template renders empty resource{{- if }} guard is false due to wrong value pathRun helm template --debug to inspect computed values
Subchart not deployingcondition: key missing from Chart.yaml or value is falseCheck dependency condition and values
Name too long (>63 chars)Missing trunc 63 in _helpers.tplAlways pipe name through `trunc 63
helm install fails schema validationValue type mismatch or missing required fieldCheck values.schema.json — add the missing constraint or fix the value type
Secrets in helm get values outputSensitive values passed with --setUse --set-string with a reference, or inject via external secrets at runtime

GitOps Integration

Flux HelmRelease

apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: myapp
namespace: myapp
spec:
interval: 10m
chart:
spec:
chart: mychart
version: "0.1.0"
sourceRef:
kind: HelmRepository
name: myrepo
namespace: flux-system
values:
replicaCount: 2
image:
tag: v1.2.3
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
remediateLastFailure: true

Argo CD Application

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myapp
namespace: argocd
spec:
project: platform-apps
source:
repoURL: https://charts.example.com
chart: mychart
targetRevision: 0.1.0
helm:
values: |
replicaCount: 2
image:
tag: v1.2.3
destination:
server: https://kubernetes.default.svc
namespace: myapp
syncPolicy:
automated:
prune: true
selfHeal: true