Skip to content
SRE & DevOpsHow-To Published Updated 6 min readViews unavailable

How to Write a Helm Chart from Scratch

Build a reusable Helm chart with safe templates, documented values, schema validation, immutable images, release tests, and OCI packaging checks.

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.

Delete example resources the application does not need and keep generated helpers only when the team understands them. Commit the chart before customization so later reviews show intentional changes. Record the Helm and Kubernetes versions used for validation because supported APIs and template behavior evolve.

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"
type: application

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.

Chart version must follow semantic versioning for dependency and repository tooling. appVersion is informational and should be quoted when it could be parsed as a number or scientific notation. Neither field automatically changes the container image unless templates connect it deliberately.

Step 3: define default configuration values

# values.yaml
replicaCount: 2
image:
  repository: registry.example.com/myapp
  digest: "sha256:REVIEWED_DIGEST"
  pullPolicy: IfNotPresent
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.

Document every public value and provide safe defaults. Add values.schema.json to reject wrong types, impossible enums, and missing required values before Kubernetes sees them. Do not put passwords or private keys in default values or rendered release history; reference an external Secret or secrets integration.

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 }}@{{ required "image.digest is required" .Values.image.digest }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          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.

In a real chart, use a shared label helper that follows Kubernetes recommended labels and generates names within length limits. Quote user-provided strings where YAML typing is dangerous, use with for optional blocks, and use nindent consistently. Avoid tpl on untrusted values because it evaluates user text as a Helm template and expands the chart’s attack surface.

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

Render more than defaults:

helm template myrelease ./mychart --values test/values-minimal.yaml
helm template myrelease ./mychart --values test/values-production.yaml
helm install myrelease ./mychart --dry-run=server --debug

Client rendering catches template errors, while server-side dry run can exercise API validation and lookups against an authorized test cluster. Inspect the rendered YAML for accidental secrets, deprecated API versions, invalid selectors, and fields that change on every render.

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.

Lint is not a Kubernetes conformance test. Add schema validation, a manifest validator using the target Kubernetes versions, and install/upgrade tests in disposable clusters. Include a helm test pod that verifies a bounded customer path, and test rollback from the previous released chart version with realistic persistent data.

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.

Use helm upgrade --install for repeatable delivery, --atomic and a realistic timeout when automatic rollback is appropriate, and --history-max to bound release history. Understand that Helm rollback restores rendered resources; it cannot reverse a destructive database migration or every hook side effect. Hooks must be idempotent, narrowly authorized, and deleted according to an intentional policy.

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.

Helm also supports storing charts in OCI registries. Authenticate through the platform’s credential mechanism, push the immutable package, and verify its digest before promotion. Signing and provenance can help consumers validate origin, but only when keys, verification policy, and distribution are operated correctly. Keep CRDs in the chart’s crds/ directory only after understanding Helm’s special installation and upgrade behavior; CRD lifecycle often needs a separate operational plan.

Design for upgrades and ownership

Treat rendered object names, selectors, volume claims, and Service ports as compatibility surfaces. Changing an immutable selector can force replacement; renaming a resource can orphan the old one; changing a PVC template can require migration. State upgrade notes in the chart release and test supported transitions, not only clean installation.

Use helm.sh/resource-policy: keep sparingly because retained resources can surprise uninstall and reinstall flows. Avoid letting two releases own the same object. Provide NOTES only for accurate next steps, and expose metrics, health probes, security context, scheduling controls, and network policy in a way that is safe by default without turning every Kubernetes field into an undocumented value.

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.

Related:

Sources:

Comments