Three control structures, and one behaviour shared by two of them that generates a wildly misleading error message. Getting the scope rule right removes most of the frustration people associate with Helm templates.
Topic 1: if, else and Truthiness
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
{{- end }}
{{- if eq .Values.service.type "LoadBalancer" }}
loadBalancerIP: {{ .Values.service.loadBalancerIP }}
{{- else if eq .Values.service.type "NodePort" }}
nodePort: {{ .Values.service.nodePort }}
{{- else }}
# ClusterIP, nothing extra
{{- end }}
Falsy in Go templates: false, 0, "", an empty list, an empty map, and nil. Everything else is true.
That 0 is a real trap: {{- if .Values.replicaCount }} skips the block when someone deliberately sets zero replicas to scale a workload down. Test explicitly when zero is meaningful:
{{- if not (kindIs "invalid" .Values.replicaCount) }}
replicas: {{ .Values.replicaCount }}
{{- end }}
The comparison and boolean functions are prefix, not infix — this is Go templates, not YAML:
{{- if and .Values.ingress.enabled .Values.ingress.className }}
{{- if or .Values.a .Values.b }}
{{- if not .Values.disabled }}
{{- if eq .Values.env "prod" }}
{{- if ne .Values.replicas 1 }}
{{- if gt (int .Values.replicas) 3 }}
{{- if contains "prod" .Release.Namespace }}
{{- if hasKey .Values "featureFlags" }}
{{- if empty .Values.nodeSelector }}
{{- if regexMatch "^v[0-9]+" .Values.image.tag }}
and and or do not short-circuit in the way you may expect — all arguments are evaluated. So and .Values.tls .Values.tls.secretName still errors when tls is nil. Nest the conditions instead:
{{- if .Values.tls }}
{{- if .Values.tls.secretName }}
...
{{- end }}
{{- end }}
Topic 2: with, and the Error It Causes
with sets the scope and skips the block entirely if the value is empty — two useful behaviours in one:
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 2 }}
{{- end }}
If nodeSelector is empty, nothing is emitted — no empty nodeSelector: key to make the manifest invalid. This is the idiomatic way to render optional maps, and it appears in every well-written chart.
The trap: inside the block, . is the value, so the root objects are unreachable.
{{- with .Values.ingress }}
host: {{ .host }} # ✓ .Values.ingress.host
name: {{ .Release.Name }} # ✗ nil pointer evaluating interface {}.Name
name: {{ $.Release.Name }} # ✓ $ is always the root
{{- end }}
The error message names .Name, which sends people to look at their release name rather than at the scope. Once you know the rule the fix is instant; before you know it, it is a genuinely bad half hour.
Topic 3: range
# a list of scalars
{{- range .Values.hosts }}
- host: {{ . | quote }}
{{- end }}
# with the index
{{- range $i, $host := .Values.hosts }}
- name: host-{{ $i }}
value: {{ $host | quote }}
{{- end }}
# a map — key and value
{{- range $key, $value := .Values.podAnnotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
# nested, which is where $ becomes essential
{{- range $host := .Values.ingress.hosts }}
- host: {{ $host.host | quote }}
http:
paths:
{{- range $path := $host.paths }}
- path: {{ $path.path }}
pathType: {{ $path.pathType | default "Prefix" }}
backend:
service:
name: {{ include "checkout.fullname" $ }} # $ = root, not $host
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
Capture named variables in a range rather than relying on the bare dot. $host and $path survive nesting; . does not, and a nested range silently rebinds it.
range over an empty list produces nothing, which is usually what you want. When you need a fallback, pair it with if:
{{- if .Values.hosts }}
{{- range .Values.hosts }}
...
{{- end }}
{{- else }}
# no hosts configured
{{- end }}
Two more forms that come up:
{{- range $i := until 3 }} # a counted loop, 0 1 2
{{- range $k, $v := .Values.env }}
- name: {{ $k }}
value: {{ $v | quote }}
{{- end }}
Note that ranging a map gives you keys in sorted order, deterministically. That matters: a template that produced randomly ordered output would create a diff on every upgrade.
Topic 4: A Complete Optional-Block Example
Putting the three together, the shape most charts need for an Ingress:
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "checkout.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "checkout.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
Note the two variables captured at the top. Inside the nested ranges, . is a path object — $fullName and $svcPort are how the values from the root remain reachable without $ littering every line.
Topic 5: Testing All the Branches
A conditional template has more than one output, and rendering it once proves one path works. Keep a values file per shape and render each:
for f in ci/*-values.yaml; do
echo "== $f"
helm template checkout . -f "$f" > /dev/null || echo "FAILED: $f"
done
ci/
├── default-values.yaml everything off
├── ingress-values.yaml ingress with TLS and several hosts
├── autoscaling-values.yaml HPA path
├── minimal-values.yaml only the required keys
└── full-values.yaml every optional block enabled
The ci/ directory name is a convention chart-testing (ct) recognises — it renders and installs the chart once per values file, which is exactly the coverage a conditional-heavy chart needs. That is covered in the delivery lesson; the directory is worth creating from the start.
Topic 6: When Template Logic Is the Wrong Answer
Charts sometimes accumulate deep conditional nesting — a template with five levels of if is usually a sign that one chart is trying to be several.
The alternatives, in order of how often they are right:
- Split the chart. A
workerchart and anapichart beat one chart with amodevalue switching every template. - Move the branch into values. Instead of
if eq .Values.env "prod"inside templates, put the difference invalues/prod.yaml. Environment names in template logic are a smell: it means the chart cannot be used by an environment its author did not anticipate. - Use a subchart with a condition for an optional component, rather than wrapping half your templates in one
if.
Try it yourself: write the nested range from Topic 3 and reference .Release.Name inside the inner loop without $. Read the error, add $, and watch it work. That is the single most valuable ten seconds in this lesson.
Common mistake: using {{ if .Values.something }} where something is a map that may be empty. An empty map is falsy, so the block is skipped — but a map with one key you did not expect is truthy, and the block renders with values that were never designed for. Prefer an explicit enabled boolean for anything a user turns on and off; it is unambiguous in both the values file and the template.