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
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.
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
{{/*
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
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
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
{{- 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
{
"$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
{{ .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
- Lint
- Render
- Dry run
- Install
helm lint ./webapp
helm lint ./webapp -f values-prod.yaml --strict
# Offline render — no cluster required
helm template demo ./webapp
# Just one file
helm template demo ./webapp -s templates/deployment.yaml
# With overrides
helm template demo ./webapp --set ingress.enabled=true --set replicaCount=4
# Renders AND validates against the live API server
helm install demo ./webapp --dry-run --debug
helm upgrade --install demo ./webapp \
--namespace demo --create-namespace \
--set replicaCount=3 \
--wait --timeout 3m
helm list -n demo
helm get manifest demo -n demo
10. Environment overlays
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
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
| Symptom | Cause |
|---|---|
error converting YAML to JSON: did not find expected key | Wrong indentation from indent where nindent was needed |
field is immutable on upgrade | A version label leaked into spec.selector |
| Values silently ignored | The key is nested under the wrong parent — check helm get values --all |
--set image.tag=1.10 produces :1.1 | Numeric coercion; use --set-string |
Release stuck pending-upgrade | A previous upgrade was interrupted; helm rollback to the last good revision |
Next steps
- Helm basics — the reference behind these commands.
- Set up a CI/CD pipeline — lint, test, and publish the chart on every PR.