CloudOpsGuide
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

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

FunctionPurpose
toYamlRender object as YAML
nindent NNewline + indent N spaces
quoteWrap in quotes
default XFallback value
requiredFail if unset
includeCall a named template
tplRender 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 change
  • appVersion: 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

  1. Pin dependency versions — "14.x.x" not "*"
  2. Write a schema — values.schema.json catches config errors
  3. Document everything — values.yaml comments + README
  4. Include NOTES.txt — tell users how to access the app
  5. Add tests — helm test validates installs
  6. Use helper templates — never repeat label logic
  7. Default to safe values — pullPolicy: IfNotPresent, reasonable resources
  8. Never hardcode namespaces — let --namespace control 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