Back to Distributed Systems & Kubernetes Series

Helm Track Part 3: Advanced Templating & Hooks

June 6, 2026 Wasil Zafar 28 min read

Once you can write a basic chart, the next challenge is real-world complexity: iterating over collections, managing chart dependencies, running database migrations before your app starts, and verifying deployments with automated tests. This part covers all of it.

Table of Contents

  1. range and with Blocks
  2. Chart Dependencies
  3. Helm Hooks
  4. helm test
  5. lookup Function
  6. required and fail
  7. Exercises
  8. Key Takeaways & Next Steps

range and with Blocks

Iterating Lists

The range action iterates over slices and maps. It's how you render repeated YAML blocks from a list of values — for example, multiple environment variables, multiple ports, or multiple ingress rules:

# values.yaml — define extra environment variables
extraEnv:
  - name: FEATURE_FLAGS
    value: "new-ui,fast-checkout"
  - name: ANALYTICS_ENDPOINT
    value: "https://analytics.internal"
  - name: MAX_CONNECTIONS
    value: "100"
# templates/deployment.yaml — render them
        env:
        - name: DB_HOST
          valueFrom:
            configMapKeyRef:
              name: {{ include "grade-api.fullname" . }}-config
              key: db.host
        {{- range .Values.extraEnv }}
        - name: {{ .name | quote }}
          value: {{ .value | quote }}
        {{- end }}
# Range with index (useful for ordered lists)
{{- range $index, $rule := .Values.ingress.rules }}
- host: {{ $rule.host | quote }}
  http:
    paths:
    - path: {{ $rule.path | default "/" }}
      {{- range $rule.backends }}
      backend:
        service:
          name: {{ .serviceName }}
          port:
            number: {{ .servicePort }}
      {{- end }}
{{- end }}

Iterating Dictionaries

# values.yaml
labels:
  team: platform
  cost-center: engineering
  compliance: pci-dss

# template — $key and $value are the variable names
metadata:
  labels:
    {{- range $key, $value := .Values.labels }}
    {{ $key }}: {{ $value | quote }}
    {{- end }}

with Block

with changes the scope (dot .) to a sub-object, avoiding repetitive .Values.some.deep.path prefixes. If the value is empty/nil/zero, the block is skipped entirely:

# Without with — verbose
{{- if .Values.ingress.annotations }}
annotations:
  {{- range $k, $v := .Values.ingress.annotations }}
  {{ $k }}: {{ $v | quote }}
  {{- end }}
{{- end }}

# With 'with' — cleaner, auto-skips if nil
{{- with .Values.ingress.annotations }}
annotations:
  {{- toYaml . | nindent 2 }}
{{- end }}

# Accessing parent scope inside 'with' using $
{{- with .Values.ingress }}
host: {{ .host }}
release: {{ $.Release.Name }}  # $ escapes back to root scope
{{- end }}

Chart Dependencies

Dependencies let your chart declare what it needs — Helm installs sub-charts alongside your chart. For grade-api, we declare PostgreSQL as a dependency so users get everything with one helm install:

helm dependency update

# After editing Chart.yaml dependencies section:
cd grade-api
helm dependency update

# Downloads:
# Fetching https://charts.bitnami.com/bitnami/postgresql-16.2.3.tgz
# Saving to charts/postgresql-16.2.3.tgz
# Creating charts/Chart.lock

# Chart.lock pins exact versions for reproducible installs
cat charts/Chart.lock
# dependencies:
# - name: postgresql
#   repository: https://charts.bitnami.com/bitnami
#   version: 16.2.3
# generated: "2026-06-06T10:00:00Z"
# digest: sha256:abc123...

# Rebuild dependencies from Chart.lock (fast, for CI)
helm dependency build

# List current dependency status
helm dependency list
# NAME          VERSION   REPOSITORY                              STATUS
# postgresql    16.2.3    https://charts.bitnami.com/bitnami      ok

Passing Values to Sub-Charts

Sub-chart values are namespaced under the dependency name in your values.yaml:

# grade-api/values.yaml — sub-chart values under 'postgresql' key
postgresql:
  enabled: true
  auth:
    database: grades
    username: grade_user
    existingSecret: grade-api-db-secret
    secretKeys:
      userPasswordKey: password
  primary:
    resources:
      requests:
        cpu: 250m
        memory: 256Mi
      limits:
        cpu: 1
        memory: 1Gi
    persistence:
      enabled: true
      size: 20Gi
  metrics:
    enabled: true

Conditional Dependencies

# Chart.yaml — condition disables sub-chart if not needed
dependencies:
  - name: postgresql
    version: "16.2.3"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled     # reads Values.postgresql.enabled
  - name: redis
    version: "19.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled

# Override when an external DB already exists (e.g., RDS in production)
# values-prod-external-db.yaml:
postgresql:
  enabled: false      # don't deploy PostgreSQL sub-chart
config:
  dbHost: "prod-db.us-east-1.rds.amazonaws.com"
Real World

Sub-Chart vs External Service: When to Use Each

In development and staging, using PostgreSQL as a sub-chart is convenient — one helm install gives you the entire stack. In production, you almost always want an external managed database (RDS, Cloud SQL, Azure Database) — set postgresql.enabled: false and point config.dbHost at your managed instance. This pattern (sub-chart for dev, external for prod) is standard across all serious Helm charts.

RDS Cloud SQL Managed Databases

Helm Hooks

Hooks are Kubernetes resources (usually Jobs) that Helm executes at specific points in the release lifecycle. The most critical use case: running database migrations before new application pods start:

Hook Types & Lifecycle

Helm Hook Execution Points in Release Lifecycle
flowchart TD
    START([helm install / upgrade]) --> PRE_INSTALL[pre-install hook runs]
    PRE_INSTALL --> INSTALL[Helm applies main resources]
    INSTALL --> POST_INSTALL[post-install hook runs]
    POST_INSTALL --> NOTES[NOTES.txt shown to user]

    START2([helm upgrade]) --> PRE_UPGRADE[pre-upgrade hook runs]
    PRE_UPGRADE --> UPGRADE[Helm applies updated resources]
    UPGRADE --> POST_UPGRADE[post-upgrade hook runs]

    START3([helm uninstall]) --> PRE_DELETE[pre-delete hook runs]
    PRE_DELETE --> DELETE[Helm deletes resources]
    DELETE --> POST_DELETE[post-delete hook runs]

    style PRE_INSTALL fill:#e8f4f4,stroke:#3B9797,color:#132440
    style PRE_UPGRADE fill:#e8f4f4,stroke:#3B9797,color:#132440
    style PRE_DELETE fill:#fff5f5,stroke:#BF092F,color:#132440
                            
# Hook annotations — applied to any K8s resource
metadata:
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade    # when to run
    "helm.sh/hook-weight": "-5"                 # order (lower = earlier)
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
    # before-hook-creation: delete old Job before creating new one
    # hook-succeeded: delete Job after it succeeds (keep logs?)
    # hook-failed: delete Job if it fails (careful with debugging)

Database Migration Hook

This is the most important hook pattern. The Job runs Flyway/Alembic/Liquibase migrations before the new application version is deployed:

# grade-api/templates/hook-db-migrate.yaml
{{- if .Values.migrations.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "grade-api.fullname" . }}-migrate-{{ .Release.Revision }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "grade-api.labels" . | nindent 4 }}
  annotations:
    # Run BEFORE both installs and upgrades — migrations must run first
    "helm.sh/hook": pre-install,pre-upgrade
    # Low weight — run before any other hooks
    "helm.sh/hook-weight": "-10"
    # Clean up the Job once it succeeds
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  backoffLimit: 3
  template:
    metadata:
      labels:
        app.kubernetes.io/component: migration
        {{- include "grade-api.selectorLabels" . | nindent 8 }}
    spec:
      restartPolicy: Never
      containers:
      - name: migrate
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        command: ["./migrate", "--direction=up", "--all"]
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: {{ .Values.secrets.dbPasswordSecretName }}
              key: database-url
        resources:
          requests:
            cpu: 100m
            memory: 128Mi
          limits:
            cpu: 500m
            memory: 256Mi
{{- end }}
# values.yaml — migration config
migrations:
  enabled: true    # disable in CI/CD if using external migration tool

Hook Weights & Delete Policies

# Multiple hooks in order using weights (lower = earlier)

# Hook 1: Wait for database to be ready (-20 weight, runs first)
# templates/hook-wait-for-db.yaml
metadata:
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-20"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded

# Hook 2: Run migrations (-10 weight, runs after DB is ready)
# templates/hook-db-migrate.yaml
metadata:
  annotations:
    "helm.sh/hook": pre-install,pre-upgrade
    "helm.sh/hook-weight": "-10"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded

# Hook 3: Seed initial data (5 weight, runs after migrations)
# templates/hook-seed-data.yaml
metadata:
  annotations:
    "helm.sh/hook": post-install      # only on first install, not upgrades
    "helm.sh/hook-weight": "5"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
Hook Failure Stops the Release: If a pre-install or pre-upgrade hook Job fails (all retries exhausted), Helm marks the release as FAILED and does not deploy your application resources. This is the desired behavior for migration hooks — you never want the new version running against an unmigrated schema. Always set backoffLimit: 3 and monitor hook Job logs with kubectl logs -n <ns> -l app.kubernetes.io/component=migration.

helm test

Writing a Test

Test pods are Jobs with the helm.sh/hook: test annotation. They run after deployment and verify that the application is actually working — not just that pods are running:

# grade-api/templates/tests/test-api-health.yaml
apiVersion: v1
kind: Pod
metadata:
  name: {{ include "grade-api.fullname" . }}-test-health
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "grade-api.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  restartPolicy: Never
  containers:
  - name: test
    image: curlimages/curl:8.6.0
    command:
    - sh
    - -c
    - |
      set -e
      echo "Testing grade-api health endpoint..."
      RESPONSE=$(curl -sf http://{{ include "grade-api.fullname" . }}/health)
      echo "Response: $RESPONSE"
      echo $RESPONSE | grep -q '"status":"ok"'
      echo "Health check passed!"

      echo "Testing grades list endpoint..."
      curl -sf http://{{ include "grade-api.fullname" . }}/api/v1/grades \
        -H "Accept: application/json" | grep -q '"grades"'
      echo "API endpoint check passed!"

Running Tests

# Run tests for a release
helm test grade-api -n production

# Output:
# NAME: grade-api
# LAST DEPLOYED: Fri Jun  6 10:00:00 2026
# NAMESPACE: production
# STATUS: deployed
# REVISION: 3
# TEST SUITE:     grade-api-test-health
# Last Started:   Fri Jun  6 10:05:00 2026
# Last Completed: Fri Jun  6 10:05:08 2026
# Phase:          Succeeded

# Keep test pods for log inspection (don't auto-delete)
helm test grade-api -n production --logs

# Show test pod logs
kubectl logs -n production grade-api-test-health

lookup Function

lookup queries the live Kubernetes API during template rendering — useful for detecting existing resources and making conditional decisions:

# Check if a Secret already exists before creating one
# (avoids overwriting existing secrets on upgrade)
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace "grade-api-db-secret" }}
{{- if not $existingSecret }}
apiVersion: v1
kind: Secret
metadata:
  name: grade-api-db-secret
  namespace: {{ .Release.Namespace }}
  annotations:
    "helm.sh/hook": pre-install
    "helm.sh/hook-delete-policy": before-hook-creation
type: Opaque
stringData:
  password: {{ randAlphaNum 32 | b64enc | quote }}
{{- end }}

# Check Kubernetes version for API compatibility
{{- if .Capabilities.APIVersions.Has "autoscaling/v2" }}
apiVersion: autoscaling/v2
{{- else }}
apiVersion: autoscaling/v2beta2
{{- end }}
lookup in dry-run: During helm install --dry-run, lookup returns an empty map — the API is not called. If your template logic depends on lookup, always test in a real cluster. Never use randAlphaNum for secrets in production — use an external secrets manager instead (see the Vault track in this series).

required and fail

Force users to provide values that have no sensible default:

# required: render fails if value is empty/nil
# Format: required "error message" .Values.path
host: {{ required "ingress.host is required when ingress.enabled=true" .Values.ingress.host }}

# Combine with if for conditional requirements
{{- if .Values.ingress.enabled }}
host: {{ required "You must set ingress.host when ingress.enabled=true" .Values.ingress.host | quote }}
{{- end }}

# fail: always fail with a custom message (useful for validation)
{{- if and .Values.ingress.tls (not .Values.ingress.host) }}
{{- fail "ingress.host must be set when ingress.tls=true" }}
{{- end }}

# Validate enum values
{{- if not (has .Values.config.logLevel (list "debug" "info" "warn" "error")) }}
{{- fail (printf "config.logLevel must be one of: debug, info, warn, error. Got: %s" .Values.config.logLevel) }}
{{- end }}

Exercises

Exercise 1 — Migration Hook: Add the hook-db-migrate.yaml from this part to the grade-api chart. Add a migrations.enabled: true value. Deploy the chart — observe the migration Job run before the application pods start by watching kubectl get pods -w -n grade-api. Then upgrade the chart with a different image tag and confirm the migration runs again.
Exercise 2 — range Loop: Add extraEnv: [] to values.yaml. Template the Deployment to render those env vars with a range loop. Test with: helm template grade-api ./grade-api --set extraEnv[0].name=DEBUG --set extraEnv[0].value=true. Confirm the env var appears in the Deployment output.
Exercise 3 — helm test: Write a test pod that sends a POST to /api/v1/grades (with a JSON body) and verifies it returns HTTP 201. Deploy the chart and run helm test grade-api -n grade-api --logs. The test should pass if the API is healthy.
Exercise 4 — required Validation: Add a required check to the ingress template that fails with a helpful error if ingress.enabled=true but ingress.host is empty. Verify it fails: helm template grade-api ./grade-api --set ingress.enabled=true (without setting the host).

Key Takeaways & Next Steps

Key Takeaways:
  • range iterates slices and maps; use $key, $value for dict iteration
  • with changes scope and short-circuits if the value is empty — cleaner than if + range
  • Chart dependencies: pin versions, use conditions to disable sub-charts for external services
  • Pre-install/upgrade hooks are the correct pattern for database migrations — failures block deployment
  • Hook weights control execution order; delete policies control cleanup
  • helm test verifies the deployment actually works — use it in CI/CD after every upgrade
  • required and fail make chart configuration errors fail early with helpful messages

Next in This Track

In Part 4: Production Patterns, we cover chart testing with helm unittest, linting in CI, OCI registry publishing, library charts, chart signing, and monorepo chart organisation patterns used by platform teams.