---
title: "Helm Chart Development: Templates, Helpers, and Testing"
description: "How to write custom Helm charts from scratch, including template functions, named templates, conditionals, dependencies, and chart testing."
url: https://agent-zone.ai/knowledge/kubernetes/helm-chart-development/
section: knowledge
date: 2026-02-22
categories: ["kubernetes"]
tags: ["helm","charts","templates","development"]
skills: ["helm-chart-authoring","template-debugging"]
tools: ["helm","kubectl"]
levels: ["intermediate"]
word_count: 899
formats:
  json: https://agent-zone.ai/knowledge/kubernetes/helm-chart-development/index.json
  html: https://agent-zone.ai/knowledge/kubernetes/helm-chart-development/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Helm+Chart+Development%3A+Templates%2C+Helpers%2C+and+Testing
---


# Helm Chart Development

Writing your own Helm charts turns static YAML into reusable, configurable packages. The learning curve is in Go's template syntax and Helm's conventions, but once you internalize the patterns, chart development is fast.

## Chart Structure

Create a new chart scaffold:

```bash
helm create my-app
```

This generates:

```
my-app/
  Chart.yaml              # chart metadata (name, version, dependencies)
  values.yaml             # default configuration values
  charts/                 # dependency charts (populated by helm dependency update)
  templates/              # Kubernetes manifest templates
    deployment.yaml
    service.yaml
    ingress.yaml
    serviceaccount.yaml
    hpa.yaml
    NOTES.txt             # post-install instructions (printed after helm install)
    _helpers.tpl           # named template definitions
    tests/
      test-connection.yaml # helm test pod
```

## Chart.yaml

The `Chart.yaml` defines your chart's identity and dependencies:

```yaml
apiVersion: v2
name: my-app
description: A Helm chart for my application
type: application
version: 0.1.0        # chart version (bump this on chart changes)
appVersion: "1.16.0"  # application version (informational)

dependencies:
  - name: postgresql
    version: "~16.0"
    repository: oci://registry-1.docker.io/bitnamicharts
    condition: postgresql.enabled
```

After editing dependencies, run:

```bash
helm dependency update ./my-app
# Downloads charts into charts/ directory and generates Chart.lock
```

## Named Templates and _helpers.tpl

The `_helpers.tpl` file defines reusable template blocks. Files starting with `_` are never rendered as Kubernetes manifests -- they only hold template definitions.

```yaml
# templates/_helpers.tpl

{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

{{- define "my-app.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
```

Use `include` (not `template`) to call named templates because `include` returns a string you can pipe through functions:

```yaml
# CORRECT: include returns a string, can be piped
labels:
  {{- include "my-app.labels" . | nindent 4 }}

# WRONG: template writes directly to output, cannot be piped
labels:
  {{ template "my-app.labels" . }}  # indentation will break
```

## Essential Template Functions

These are the functions you will use constantly:

```yaml
# nindent: add newline + indent (critical for YAML structure)
metadata:
  labels:
    {{- include "my-app.labels" . | nindent 4 }}

# toYaml: convert a values structure to YAML
resources:
  {{- toYaml .Values.resources | nindent 2 }}

# default: fallback value
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

# quote: wrap in quotes (necessary for strings that look like numbers)
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum | quote }}

# tpl: render a string as a template (for values that contain template expressions)
env:
  - name: DATABASE_URL
    value: {{ tpl .Values.databaseUrl . }}
# values.yaml: databaseUrl: "postgresql://{{ .Release.Name }}-db:5432/app"

# required: fail with a message if value is missing
{{ required "image.repository is required" .Values.image.repository }}
```

## Conditionals and Loops

```yaml
# Conditional blocks
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "my-app.fullname" . }}
  {{- with .Values.ingress.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "my-app.fullname" $ }}
                port:
                  number: {{ $.Values.service.port }}
          {{- end }}
    {{- end }}
{{- end }}
```

Note the `$` in `$.Values` inside the range loop. Inside `range`, the dot (`.`) is rebound to the current item. Use `$` to access the root context.

The `with` block rebinds `.` to its argument if the argument is non-empty, and skips the block if it is empty:

```yaml
{{- with .Values.nodeSelector }}
nodeSelector:
  {{- toYaml . | nindent 2 }}
{{- end }}
```

## Whitespace Control

The `{{-` and `-}}` markers trim whitespace. Without them, your YAML will have blank lines and broken indentation:

```yaml
# Without whitespace control: produces blank lines
{{ if .Values.debug }}
  debug: true
{{ end }}

# With whitespace control: clean output
{{- if .Values.debug }}
  debug: true
{{- end }}
```

## Linting and Validation

```bash
# Lint checks for common errors (missing required fields, bad YAML)
helm lint ./my-app
helm lint ./my-app -f values/production.yaml

# Template rendering to verify output
helm template test-release ./my-app -f values/production.yaml

# Render a single template
helm template test-release ./my-app -s templates/deployment.yaml

# Validate against cluster API (catches CRD issues, API version problems)
helm template test-release ./my-app | kubectl apply --dry-run=server -f -
```

## Chart Testing with helm test

Define test pods in `templates/tests/`. Helm runs them with `helm test` and reports pass/fail based on exit codes:

```yaml
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "my-app.fullname" . }}-test-connection"
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
spec:
  containers:
    - name: wget
      image: busybox
      command: ['wget']
      args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}']
  restartPolicy: Never
```

```bash
helm test my-release -n my-namespace
```

## Subchart Values

Pass values to dependency charts by nesting under the dependency name in `values.yaml`:

```yaml
postgresql:
  enabled: true
  auth:
    database: myapp
    username: appuser
```

The `condition: postgresql.enabled` in `Chart.yaml` means the entire subchart is skipped when `postgresql.enabled` is `false`.

## Debugging Tips

When templates produce unexpected output, add a debug template that dumps all values:

```bash
# Show all computed values
helm template my-release ./my-app --debug 2>&1 | head -50

# Show what Helm sees for a specific value path
helm template my-release ./my-app --show-only templates/deployment.yaml
```

Common mistakes: forgetting `nindent` (YAML indentation errors), using `template` instead of `include`, referencing `.Values` inside a `range` or `with` block without `$`, and not quoting values that look like booleans or numbers.

