How to Write a Helm Chart from Scratch
A complete walkthrough building a Helm chart for a simple application — templates, values, and the conventions that make a chart genuinely reusable rather than a one-off wrapper around raw YAML.
A Helm chart packages a Kubernetes application’s manifests as reusable, parameterized templates — this walks through building one for a simple web application from scratch.
Step 1: scaffold a new chart
helm create mychart
This generates a conventional chart directory structure: Chart.yaml (metadata), values.yaml (default configuration), and a templates/ directory with example manifests already following Helm’s conventions.
Step 2: fill in chart metadata
# Chart.yaml
apiVersion: v2
name: mychart
description: A Helm chart for my application
version: 0.1.0
appVersion: "1.0.0"
version tracks the chart’s own version (incrementing as you change the templates); appVersion tracks the version of the application the chart deploys — these are deliberately independent, since a chart’s packaging can change without the application itself changing, and vice versa.
Step 3: define default configuration values
# values.yaml
replicaCount: 2
image:
repository: myapp
tag: "1.0.0"
service:
port: 80
resources:
requests:
cpu: 100m
memory: 128Mi
values.yaml is the chart’s public configuration surface — anything a user of the chart should be able to customize belongs here, referenced from templates rather than hardcoded.
Step 4: template the Deployment manifest
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-mychart
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels: {app: {{ .Release.Name }}}
template:
metadata:
labels: {app: {{ .Release.Name }}}
spec:
containers:
- name: mychart
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources: {{ toYaml .Values.resources | nindent 12 }}
{{ .Values.* }} references pull from values.yaml; {{ .Release.Name }} is a built-in variable set to whatever name the chart is installed under — using it instead of a hardcoded name lets the same chart be installed multiple times under different release names without colliding.
Step 5: template the Service manifest
# templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-mychart
spec:
selector: {app: {{ .Release.Name }}}
ports:
- port: {{ .Values.service.port }}
targetPort: 8080
Step 6: validate the templates render correctly before installing
helm template mychart ./mychart
This renders the final Kubernetes YAML the chart would produce, without actually installing anything — the fastest way to catch a templating mistake before it becomes a failed helm install.
Step 7: lint the chart for common mistakes
helm lint ./mychart
helm lint checks for structural problems and common conventions the chart might be violating, beyond just whether the templates render without error.
Step 8: install the chart
helm install myrelease ./mychart --set replicaCount=3
--set overrides specific values from the command line without needing a separate values file — useful for quick overrides, though a dedicated values file (-f custom-values.yaml) is generally more maintainable for anything beyond a one-off change.
Step 9: package the chart for distribution
helm package ./mychart
This produces a versioned .tgz archive suitable for uploading to a chart repository, the same distribution format used by public charts like kube-prometheus-stack.
Why separating values from templates is the entire point
The distinction between values.yaml (what a user can configure) and templates/ (how those values get woven into actual Kubernetes manifests) is what makes a chart genuinely reusable across different environments and deployments, rather than being a one-off YAML file with find-and-replace placeholders. Getting this separation right — deciding what genuinely belongs in values.yaml versus what should stay fixed in the templates — is the actual design skill in writing a good chart, more so than Helm’s templating syntax itself.