Skip to main content

Write your first Helm chart

We will turn the Deployment and Service from Deploy your first app into a reusable chart that works across environments, then package and publish it.

Time: about 30 minutes. Prerequisites: Helm 3.14+, a running cluster (kind is fine).

helm version

1. Scaffold

helm create webapp

helm create produces a working chart with an Ingress, ServiceAccount, HPA, and a test hook. It is a good reference, but for learning it is clearer to start from a trimmed tree:

cd webapp
rm -rf templates/*

We will write each file deliberately.

2. Chart.yaml

webapp/Chart.yaml
apiVersion: v2
name: webapp
description: A configurable HTTP service for Kubernetes
type: application

# The chart's own version. Bump on every chart change, following SemVer.
version: 0.1.0

# The version of the application this chart deploys by default.
appVersion: '1.27.3'

keywords: [web, http, nginx]
home: https://github.com/mdryaaan/nativehub
maintainers:
- name: Md Raiyan

3. values.yaml

Values are the chart's public API. Design them as you would design a function signature: shallow, predictable, and with sensible defaults.

webapp/values.yaml
replicaCount: 2

image:
repository: nginxinc/nginx-unprivileged
tag: '' # defaults to .Chart.AppVersion when empty
pullPolicy: IfNotPresent

nameOverride: ''
fullnameOverride: ''

service:
type: ClusterIP
port: 80
targetPort: 8080

resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi

podSecurityContext:
runAsNonRoot: true
runAsUser: 101
seccompProfile:
type: RuntimeDefault

securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ['ALL']

ingress:
enabled: false
className: nginx
host: webapp.example.com
tls:
enabled: false
secretName: ''

autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 75

nodeSelector: {}
tolerations: []
affinity: {}
podAnnotations: {}

4. Helpers

webapp/templates/_helpers.tpl
{{/*
Chart name, overridable. Truncated to 63 characters because that is the limit
for Kubernetes label values and most name fields.
*/}}
{{- define "webapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Fully qualified name: "<release>-<chart>", unless the release name already
contains the chart name, in which case the release name is used as-is.
*/}}
{{- define "webapp.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 }}

{{/* Labels used in selectors. These are immutable after install — never add
anything version-dependent here. */}}
{{- define "webapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "webapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/* The full recommended label set, safe to change between revisions. */}}
{{- define "webapp.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{ include "webapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

5. Deployment template

webapp/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "webapp.fullname" . }}
labels:
{{- include "webapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "webapp.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "webapp.selectorLabels" . | nindent 8 }}
spec:
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 15
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}

Note the {{- if not .Values.autoscaling.enabled }} guard around replicas. If an HPA owns the replica count, emitting replicas from the chart makes every helm upgrade fight the autoscaler and briefly reset the count.

6. Service and Ingress

webapp/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "webapp.fullname" . }}
labels:
{{- include "webapp.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "webapp.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
webapp/templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "webapp.fullname" . -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "webapp.labels" . | nindent 4 }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host | quote }}
secretName: {{ .Values.ingress.tls.secretName | default (printf "%s-tls" $fullName) }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $fullName }}
port:
number: {{ .Values.service.port }}
{{- end }}

The whole file is wrapped in an if. When ingress.enabled is false the template renders to nothing and Helm skips the empty document entirely.

7. Validate values with a schema

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

Helm validates the merged values against this schema on install, upgrade, lint, and template. A typo like replicaCount: "3" now fails immediately with a clear message instead of producing a broken manifest.

8. NOTES.txt

webapp/templates/NOTES.txt
{{ .Chart.Name }} {{ .Chart.Version }} installed as release "{{ .Release.Name }}".

{{ if .Values.ingress.enabled -}}
Your app is available at http{{ if .Values.ingress.tls.enabled }}s{{ end }}://{{ .Values.ingress.host }}
{{- else if eq .Values.service.type "ClusterIP" -}}
Reach it from your machine with:

kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "webapp.fullname" . }} 8080:{{ .Values.service.port }}

Then open http://localhost:8080
{{- end }}

Check the rollout:

kubectl rollout status -n {{ .Release.Namespace }} deployment/{{ include "webapp.fullname" . }}

9. Render before you install

helm lint ./webapp
helm lint ./webapp -f values-prod.yaml --strict

10. Environment overlays

values-prod.yaml
replicaCount: 6

resources:
requests:
cpu: 250m
memory: 256Mi
limits:
memory: 512Mi

ingress:
enabled: true
host: webapp.nativehub.example.com
tls:
enabled: true

autoscaling:
enabled: true
minReplicas: 6
maxReplicas: 30
helm upgrade --install webapp ./webapp \
-n prod -f values-prod.yaml \
--set image.tag=1.27.3 \
--atomic --timeout 5m

--atomic rolls the release back automatically if the upgrade does not become ready in time, so a bad deploy never leaves you half-applied.

11. A chart test

webapp/templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "webapp.fullname" . }}-test-connection"
labels:
{{- include "webapp.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
"helm.sh/hook-delete-policy": hook-succeeded
spec:
restartPolicy: Never
containers:
- name: curl
image: curlimages/curl:8.11.0
command: ["curl"]
args:
- "--fail"
- "--silent"
- "--show-error"
- "http://{{ include "webapp.fullname" . }}:{{ .Values.service.port }}/"
helm test demo -n demo --logs

12. Package and publish

helm package ./webapp # -> webapp-0.1.0.tgz

# OCI registries are the modern distribution path — no chart repo index needed
helm registry login ghcr.io -u OWNER
helm push webapp-0.1.0.tgz oci://ghcr.io/OWNER/charts

# Consumers install straight from the registry
helm install demo oci://ghcr.io/OWNER/charts/webapp --version 0.1.0

Common mistakes

SymptomCause
error converting YAML to JSON: did not find expected keyWrong indentation from indent where nindent was needed
field is immutable on upgradeA version label leaked into spec.selector
Values silently ignoredThe key is nested under the wrong parent — check helm get values --all
--set image.tag=1.10 produces :1.1Numeric coercion; use --set-string
Release stuck pending-upgradeA previous upgrade was interrupted; helm rollback to the last good revision

Next steps