helm
Helm Charts: Create from Scratch
Intermediate
16 minutes
October 2026
CloudOpsGuide Team
Helm Charts: Create from Scratch
Step-by-step guide to creating custom Helm charts — from helm create through templates, values, and packaging for distribution.
Table of Contents
- Chart Anatomy
- Creating the Chart
- Writing Templates
- Testing the Chart
- Packaging and Distribution
- Chart.yaml Deep Dive
Chart Anatomy
my-app/
├── Chart.yaml # metadata (name, version, description)
├── values.yaml # default config values
├── values.schema.json # validation schema (optional)
├── templates/ # k8s manifests with Go templating
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── _helpers.tpl # reusable template snippets
│ └── NOTES.txt # post-install instructions
└── charts/ # dependent subcharts
Creating the Chart
helm create my-app
This scaffolds a working chart with a deployment, service, and helpers. Strip what you don't need:
# Minimal starting point
cd my-app
rm -rf templates/tests templates/ingress.yaml templates/hpa.yaml templates/serviceaccount.yaml
Writing Templates
Template Syntax Basics
# {{ }} = template action
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
# Conditionals
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
...
{{- end }}
# Loops
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
The Helpers File
templates/_helpers.tpl keeps templates DRY:
{{- define "my-app.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "my-app.fullname" -}}
{{- printf "%s-%s" .Release.Name (include "my-app.name" .) | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "my-app.labels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end }}
Use them:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ include "my-app.name" . }}
template:
metadata:
labels:
app: {{ include "my-app.name" . }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
ports:
- containerPort: {{ .Values.service.targetPort }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.env }}
env:
{{- toYaml . | nindent 12 }}
{{- end }}
Key Functions
| Function | Purpose |
|---|---|
toYaml | Render object as YAML |
nindent N | Newline + indent N spaces |
quote | Wrap in quotes |
default X | Fallback value |
required | Fail if unset |
include | Call a named template |
tpl | Render string as template |
tpl .Values.appConfig . | Render a user's value with templates inside |
Testing the Chart
Lint
helm lint ./my-app
Dry Render
# Render templates without installing
helm template my-release ./my-app
# With custom values
helm template my-release ./my-app -f values-prod.yaml
# See what changed
helm template my-release ./my-app | less
Dry Run Install
helm install my-release ./my-app --dry-run --debug
Chart Tests
templates/tests/test-connection.yaml:
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-app.fullname" . }}-test"
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
helm test my-release
Packaging and Distribution
Package
helm package ./my-app
# → my-app-1.0.0.tgz
Host on GitHub Pages (free)
# 1. Package
helm package ./my-app -d docs/
# 2. Build index
helm repo index docs/ --url https://myorg.github.io/charts
# 3. Push docs/ to gh-pages branch
git push origin gh-pages
# 4. Users add your repo
helm repo add myorg https://myorg.github.io/charts
helm install my-app myorg/my-app
OCI Registry (modern approach)
# Push to any OCI registry (ACR, GHCR, Docker Hub)
helm push my-app-1.0.0.tgz oci://registry.example.com/charts
# Install
helm install my-app oci://registry.example.com/charts/my-app --version 1.0.0
Chart.yaml Deep Dive
apiVersion: v2 # required
name: my-app # chart name
description: My app chart # one-liner
type: application # application | library
version: 1.2.0 # chart version (SemVer)
appVersion: "2.4.1" # version of the app being deployed
icon: https://example.com/icon.png
dependencies: # subcharts
- name: postgresql
version: "14.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
maintainers:
- name: Your Name
email: you@example.com
keywords:
- kubernetes
- web
Version Semantics
version: bump when templates changeappVersion: bump when the app changes- Keep them independent — chart v1.2.0 can deploy app v2.4.1
Common Patterns
Required Values
image: {{ required "image.repository is required" .Values.image.repository }}
Optional Resources
{{- if .Values.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
...
{{- end }}
Rendering Complex Data
{{- range $key, $val := .Values.extraEnv }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
Best Practices
- Pin dependency versions —
"14.x.x"not"*" - Write a schema —
values.schema.jsoncatches config errors - Document everything —
values.yamlcomments + README - Include NOTES.txt — tell users how to access the app
- Add tests —
helm testvalidates installs - Use helper templates — never repeat label logic
- Default to safe values —
pullPolicy: IfNotPresent, reasonable resources - Never hardcode namespaces — let
--namespacecontrol it
Useful Commands
helm create my-app # scaffold
helm lint ./my-app # validate
helm template rel ./my-app # render
helm install rel ./my-app --dry-run --debug # preview
helm package ./my-app # build .tgz
helm dep update ./my-app # fetch dependencies
Related Articles
Last Updated: October 2026
Author: CloudOpsGuide Team
Difficulty: Intermediate
Estimated Reading Time: 16 minutes