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"
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.
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
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
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 }}
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
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.
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.
/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.
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
rangeiterates slices and maps; use$key, $valuefor dict iterationwithchanges scope and short-circuits if the value is empty — cleaner thanif + 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 testverifies the deployment actually works — use it in CI/CD after every upgraderequiredandfailmake 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.