Templating
This guide provides best practices for using templating in your Zarf packages.
Chain functions together using the pipe operator (|) for clarity:
# ✅ GOOD: Clear transformation pipelinename: {{ .Values.app.name | lower | kebabcase | trunc 50 }}
# ❌ AVOID: Nested function callsname: {{ trunc 50 (kebabcase (lower .Values.app.name)) }}Use the quote function to ensure string values are properly quoted in YAML:
# ✅ GOOD: Properly quotedenvironment: {{ .Values.app.environment | quote }}image: {{ .Values.app.image | quote }}
# ❌ RISKY: Can cause YAML parsing issuesenvironment: {{ .Values.app.environment }}Why this matters:
- Prevents YAML parsing errors with special characters
- Handles values like
"yes","no","true","false"correctly - Protects against values that look like numbers
Example of the problem:
# Without quote, this breaks YAML parsingapiVersion: {{ .Values.version }} # If version is "1.0", breaksapiVersion: {{ .Values.version | quote }} # "1.0" works correctlyUse the default function to provide fallback values:
# ✅ GOOD: Safe with defaultsreplicas: {{ .Values.app.replicas | default 3 }}namespace: {{ .Values.app.namespace | default "default" | quote }}logLevel: {{ .Values.app.logLevel | default "info" | quote }}
# ❌ RISKY: Fails if value is missingreplicas: {{ .Values.app.replicas }}When to use defaults:
- Optional configuration values
- Values that have sensible fallbacks
- Any value that might not be provided by users
Combine with other functions:
# Default, then transformenvironment: {{ .Values.app.environment | default "production" | upper | quote }}Use whitespace control operators (-) to manage output formatting:
# ✅ GOOD: Clean output, no extra blank lines{{- if .Values.app.debug }}debug: true{{- end }}
# ❌ PRODUCES UNWANTED BLANK LINES{{ if .Values.app.debug }}debug: true{{ end }}Whitespace control operators:
{{-removes whitespace before the template-}}removes whitespace after the template{{- -}}removes whitespace on both sides
Example output difference:
# Without whitespace control:metadata: labels:
app: nginx
# With whitespace control:metadata: labels: app: nginxAdd comments to explain non-obvious template logic:
# ✅ GOOD: Explained logic# Calculate scaled replica count based on environment# Production gets 3x replicas, non-production gets 1x{{- $baseReplicas := .Values.app.replicas | default 1 -}}{{- $scaleFactor := .Values.app.production | ternary 3 1 -}}replicas: {{ mul $baseReplicas $scaleFactor }}
# ❌ UNCLEAR: What's happening here?{{- $baseReplicas := .Values.app.replicas | default 1 -}}{{- $scaleFactor := .Values.app.production | ternary 3 1 -}}replicas: {{ mul $baseReplicas $scaleFactor }}What to comment:
- Complex conditionals
- Non-obvious default values
- Business logic encoded in templates
Keep templates simple and prefer data over complex logic:
# ✅ GOOD: Simple conditional{{- if .Values.monitoring.enabled }}annotations: prometheus.io/scrape: "true"{{- end }}
# ❌ AVOID: Complex nested logic{{- if and .Values.monitoring.enabled (or (eq .Values.app.environment "production") (eq .Values.app.environment "staging")) (not .Values.app.debug) }}annotations: prometheus.io/scrape: "true" prometheus.io/port: {{ .Values.monitoring.port | default 8080 | quote }}{{- end }}Better approach for complex cases:
Move logic to values files:
monitoring: enabled: true annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080"# manifest{{- if .Values.monitoring.enabled }}annotations:{{- range $key, $value := .Values.monitoring.annotations }} {{ $key }}: {{ $value | quote }}{{- end }}{{- end }}Define template variables to avoid repeating complex expressions:
# ✅ GOOD: Define once, use many times{{- $appName := .Values.app.name | lower | kebabcase -}}{{- $namespace := .Values.app.namespace | default "default" -}}
apiVersion: v1kind: Servicemetadata: name: {{ $appName }}-service namespace: {{ $namespace }}spec: selector: app: {{ $appName }}---apiVersion: apps/v1kind: Deploymentmetadata: name: {{ $appName }}-deployment namespace: {{ $namespace }}spec: selector: matchLabels: app: {{ $appName }}
# ❌ AVOID: Repeating the same expressionapiVersion: v1kind: Servicemetadata: name: {{ .Values.app.name | lower | kebabcase }}-service namespace: {{ .Values.app.namespace | default "default" }}spec: selector: app: {{ .Values.app.name | lower | kebabcase }}Convert types explicitly when needed:
# ✅ GOOD: Explicit type conversionapiVersion: v1kind: ConfigMapdata: # Numbers must be strings in ConfigMaps port: {{ .Values.app.port | toString | quote }} replicas: {{ .Values.app.replicas | toString | quote }}
# Booleans to strings debug: {{ .Values.app.debug | toString | quote }}
# ❌ RISKY: Type mismatch errorsdata: port: {{ .Values.app.port }} # May fail if port is a numberCommon conversions:
toString- Convert to stringtoInt- Convert to integertoYaml- Convert object to YAMLtoJson- Convert object to JSON
Always preview your templates before deploying:
# Preview manifests with default valueszarf dev inspect manifests --features="values=true"
# Preview with custom valueszarf dev inspect manifests \ --features="values=true" \ -f custom-values.yaml
# Preview with inline overrideszarf dev inspect manifests \ --features="values=true" \ --set-values="app.replicas=5,app.environment=staging"What to check:
- Templates render without errors
- Output looks as expected
- No unexpected whitespace
- Values are correctly interpolated
- Conditionals evaluate correctly
Protect against missing or nil values:
# ✅ GOOD: Safe against missing values{{- if .Values.monitoring }} {{- if .Values.monitoring.enabled }}annotations: prometheus.io/scrape: "true" {{- end }}{{- end }}
# Alternative: Use default for the entire object{{- $monitoring := .Values.monitoring | default dict -}}{{- if $monitoring.enabled }}annotations: prometheus.io/scrape: "true"{{- end }}
# ❌ RISKY: Will fail if monitoring is undefined{{- if .Values.monitoring.enabled }}annotations: prometheus.io/scrape: "true"{{- end }}Use the indent and nindent functions consistently:
# ✅ GOOD: Proper indentationapiVersion: v1kind: ConfigMapdata: config.yaml: |{{ .Values.app.config | toYaml | indent 4 }}
# ✅ ALSO GOOD: Using nindent (newline + indent)apiVersion: v1kind: ConfigMapdata: config.yaml: |-{{- .Values.app.config | toYaml | nindent 4 }}
# ❌ AVOID: Inconsistent indentationapiVersion: v1kind: ConfigMapdata: config.yaml: | {{ .Values.app.config | toYaml }}Choose the right function:
indent N- Indent every line by N spacesnindent N- Add newline, then indent by N spaces
Error:
Error: template: :5:14: executing "" at <.Values.app.missing>: map has no entry for key "missing"Solution:
# Use default for optional valuesvalue: {{ .Values.app.missing | default "fallback" }}
# Or check if exists first{{- if .Values.app.missing }}value: {{ .Values.app.missing }}{{- end }}Error:
Error: wrong type for value; expected string; got intSolution:
# Convert types explicitlyvalue: {{ .Values.app.port | toString | quote }}Problem: Extra blank lines or spacing in output
Solution:
# Use whitespace control operators{{- if .Values.app.debug -}}debug: true{{- end -}}Problem: YAML parsing fails with boolean-like strings
Solution:
# Always quote string valuesvalue: {{ .Values.app.setting | quote }}- Templating Reference - Complete templating documentation
- Package Values - Defining and using package values
- Sprig Function Documentation - Complete Sprig reference
- values-templating Example - Working examples