Helm Values and Templates - Advanced Techniques

Mastering Helm's templating system transforms static Kubernetes manifests into dynamic, configurable deployments. The combination of structured values and powerful templates enables teams to maintain a single chart that adapts to different environments, configurations, and deployment scenarios. Understanding advanced templating patterns and value management strategies is essential for building maintainable, production-ready Helm charts.

Template Syntax Fundamentals

Helm uses Go's templating language with additional functions for Kubernetes-specific operations:

graph TB A[Template Syntax] --> B[Actions] A --> C[Pipelines] A --> D[Functions] A --> E[Control Structures] B --> B1[{{ .Values.key }}] B --> B2[{{- whitespace control -}}] C --> C1[{{ .Values.key | quote }}] C --> C2[{{ .Values.key | default "value" }}] D --> D1[Built-in: quote, upper, lower] D --> D2[Helm: include, toYaml, toJson] D --> D3[Sprig: trunc, replace, regex] E --> E1[{{- if }}] E --> E2[{{- range }}] E --> E3[{{- with }}]

Basic Actions:

# Simple variable substitution name: {{ .Values.app.name }} # With whitespace control (removes newlines) labels: {{- include "myapp.labels" . | nindent 2 }} # Comments (not rendered) {{- /* This is a comment */ -}}

Whitespace Control:

Working with Values

Values flow from multiple sources with increasing priority:

flowchart LR A[values.yaml
Default values] --> B[Parent Chart Values] B --> C[-f custom.yaml
Value files] C --> D[--set flag
Command line] D --> E[Final Values] style E fill:#90EE90

Accessing Values:

# Direct access image: {{ .Values.image.repository }}:{{ .Values.image.tag }} # Nested access database: host: {{ .Values.db.connection.host }} port: {{ .Values.db.connection.port }} # Access chart metadata version: {{ .Chart.Version }} appVersion: {{ .Chart.AppVersion }} # Access release information releaseName: {{ .Release.Name }} namespace: {{ .Release.Namespace }}

Built-in Objects:

# .Values - Values from values.yaml and overrides {{ .Values.replicaCount }} # .Chart - Contents of Chart.yaml {{ .Chart.Name }}-{{ .Chart.Version }} # .Release - Information about the release {{ .Release.Name }} {{ .Release.Namespace }} {{ .Release.Service }} # Always "Helm" # .Capabilities - Cluster capabilities {{ .Capabilities.KubeVersion }} {{ .Capabilities.APIVersions }} # .Template - Current template information {{ .Template.Name }} {{ .Template.BasePath }} # .Files - Access to non-special files in the chart {{ .Files.Get "config.txt" }}

Conditional Logic

Control what gets rendered based on values:

Simple Conditionals:

{{- if .Values.ingress.enabled }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "myapp.fullname" . }} spec: # Ingress configuration {{- end }}

If-Else:

apiVersion: v1 kind: Service metadata: name: {{ include "myapp.fullname" . }} spec: type: {{ .Values.service.type }} {{- if eq .Values.service.type "LoadBalancer" }} loadBalancerIP: {{ .Values.service.loadBalancerIP }} {{- else if eq .Values.service.type "NodePort" }} nodePort: {{ .Values.service.nodePort }} {{- end }}

Complex Conditions:

{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} apiVersion: v1 kind: PersistentVolumeClaim metadata: name: {{ include "myapp.fullname" . }}-data spec: accessModes: - {{ .Values.persistence.accessMode }} resources: requests: storage: {{ .Values.persistence.size }} {{- if .Values.persistence.storageClass }} storageClassName: {{ .Values.persistence.storageClass }} {{- end }} {{- end }}

Conditional Functions:

Loops and Iteration

Iterate over lists and maps:

Range over Lists:

env: {{- range .Values.env }} - name: {{ .name }} value: {{ .value | quote }} {{- end }} # With values.yaml: # env: # - name: LOG_LEVEL # value: info # - name: PORT # value: "8080"

Range over Maps:

data: {{- range $key, $value := .Values.configData }} {{ $key }}: {{ $value | quote }} {{- end }} # With values.yaml: # configData: # database.host: "postgres" # database.port: "5432" # cache.ttl: "3600"

Range with Index:

hosts: {{- range $index, $host := .Values.ingress.hosts }} - host: {{ $host }} http: paths: - path: / backend: serviceName: {{ include "myapp.fullname" $ }}-{{ $index }} servicePort: 80 {{- end }}

Note: Use $ to reference the root context within loops.

Variables

Create and use variables within templates:

{{- $fullName := include "myapp.fullname" . -}} {{- $chartName := .Chart.Name -}} {{- $labels := include "myapp.labels" . -}} apiVersion: v1 kind: ConfigMap metadata: name: {{ $fullName }}-config labels: {{- $labels | nindent 4 }} data: app: {{ $chartName }} instance: {{ $fullName }}

Variables in Loops:

{{- $root := . -}} {{- range .Values.services }} --- apiVersion: v1 kind: Service metadata: name: {{ include "myapp.fullname" $root }}-{{ .name }} labels: service: {{ .name }} spec: type: {{ .type }} ports: - port: {{ .port }} selector: app: {{ include "myapp.name" $root }} {{- end }}

Template Functions

Helm includes powerful functions from Sprig library:

String Functions:

# Quote values value: {{ .Values.setting | quote }} # Uppercase/lowercase name: {{ .Values.name | upper }} env: {{ .Values.environment | lower }} # Truncate and trim shortName: {{ .Values.fullName | trunc 20 | trimSuffix "-" }} # Replace strings sanitized: {{ .Values.input | replace " " "-" | lower }} # Concatenate fullPath: {{ printf "%s/%s" .Values.basePath .Values.fileName }}

Default Values:

# Provide default if value is empty image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }} # Default for nested values replicas: {{ .Values.replicaCount | default 3 }} # Complex defaults port: {{ .Values.service.port | default (ternary 443 80 .Values.tls.enabled) }}

List Functions:

# First element primaryHost: {{ .Values.ingress.hosts | first }} # Last element backupHost: {{ .Values.ingress.hosts | last }} # Has element {{- if has "production" .Values.environments }} # Production-specific config {{- end }} # Append to list {{- $ports := list 80 443 }} {{- $ports = append $ports 8080 }} ports: {{ $ports }}

YAML and JSON Functions:

# Convert to YAML (most common) resources: {{- toYaml .Values.resources | nindent 2 }} # Convert to JSON config: {{ .Values.appConfig | toJson }} # Convert from JSON {{- $parsed := .Values.jsonString | fromJson }} value: {{ $parsed.key }}

Indentation Functions:

# Indent by N spaces spec: {{ .Values.podSpec | indent 2 }} # Newline + indent (most common) labels: {{- include "myapp.labels" . | nindent 2 }} # Indent without first line annotations: {{- .Values.annotations | nindent 2 }}

Advanced Templating Patterns

Generating Multiple Resources from Values

# values.yaml databases: - name: users size: 10Gi storageClass: fast - name: sessions size: 5Gi storageClass: standard - name: analytics size: 50Gi storageClass: bulk # templates/databases.yaml {{- range .Values.databases }} --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: {{ include "myapp.fullname" $ }}-{{ .name }}-pvc labels: {{- include "myapp.labels" $ | nindent 4 }} database: {{ .name }} spec: accessModes: - ReadWriteOnce storageClassName: {{ .storageClass }} resources: requests: storage: {{ .size }} --- apiVersion: v1 kind: Service metadata: name: {{ include "myapp.fullname" $ }}-{{ .name }} labels: {{- include "myapp.labels" $ | nindent 4 }} database: {{ .name }} spec: type: ClusterIP ports: - port: 5432 targetPort: postgres selector: database: {{ .name }} {{- end }}

Dynamic Environment Variables

# values.yaml envVars: simple: LOG_LEVEL: info PORT: "8080" fromSecret: DATABASE_PASSWORD: secretName: db-credentials key: password API_KEY: secretName: api-credentials key: key fromConfigMap: CONFIG_FILE: configMapName: app-config key: config.json # templates/deployment.yaml env: {{- range $key, $value := .Values.envVars.simple }} - name: {{ $key }} value: {{ $value | quote }} {{- end }} {{- range $key, $value := .Values.envVars.fromSecret }} - name: {{ $key }} valueFrom: secretKeyRef: name: {{ $value.secretName }} key: {{ $value.key }} {{- end }} {{- range $key, $value := .Values.envVars.fromConfigMap }} - name: {{ $key }} valueFrom: configMapKeyRef: name: {{ $value.configMapName }} key: {{ $value.key }} {{- end }}

Conditional Resource Limits

# templates/deployment.yaml resources: {{- if .Values.resources }} {{- if or .Values.resources.limits .Values.resources.requests }} {{- if .Values.resources.limits }} limits: {{- if .Values.resources.limits.cpu }} cpu: {{ .Values.resources.limits.cpu }} {{- end }} {{- if .Values.resources.limits.memory }} memory: {{ .Values.resources.limits.memory }} {{- end }} {{- end }} {{- if .Values.resources.requests }} requests: {{- if .Values.resources.requests.cpu }} cpu: {{ .Values.resources.requests.cpu }} {{- end }} {{- if .Values.resources.requests.memory }} memory: {{ .Values.resources.requests.memory }} {{- end }} {{- end }} {{- end }} {{- end }} # Cleaner approach using toYaml resources: {{- toYaml .Values.resources | nindent 2 }}

Environment-Specific Configuration

# values.yaml global: domain: example.com environments: development: replicas: 1 resources: limits: cpu: 500m memory: 256Mi staging: replicas: 2 resources: limits: cpu: 1000m memory: 512Mi production: replicas: 5 resources: limits: cpu: 2000m memory: 1Gi # Environment is selected via values environment: production # templates/deployment.yaml {{- $env := index .Values.environments .Values.environment }} spec: replicas: {{ $env.replicas }} template: spec: containers: - name: app resources: {{- toYaml $env.resources | nindent 10 }}

Named Templates and Includes

Create reusable template functions:

templates/_helpers.tpl:

{{/* Generate labels */}} {{- define "myapp.labels" -}} app.kubernetes.io/name: {{ include "myapp.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/managed-by: {{ .Release.Service }} helm.sh/chart: {{ include "myapp.chart" . }} {{- if .Values.customLabels }} {{ toYaml .Values.customLabels }} {{- end }} {{- end }} {{/* Generate selector labels (subset of all labels) */}} {{- define "myapp.selectorLabels" -}} app.kubernetes.io/name: {{ include "myapp.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} {{/* Generate full name with suffix */}} {{- define "myapp.fullnameWithSuffix" -}} {{- $name := include "myapp.fullname" . -}} {{- $suffix := . | index 1 -}} {{- printf "%s-%s" $name $suffix | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Generate container image string */}} {{- define "myapp.image" -}} {{- $registry := .Values.image.registry | default "docker.io" -}} {{- $repository := .Values.image.repository -}} {{- $tag := .Values.image.tag | default .Chart.AppVersion -}} {{- printf "%s/%s:%s" $registry $repository $tag }} {{- end }} {{/* Generate container pull policy */}} {{- define "myapp.pullPolicy" -}} {{- if .Values.image.pullPolicy -}} {{ .Values.image.pullPolicy }} {{- else if eq .Values.image.tag "latest" -}} Always {{- else -}} IfNotPresent {{- end -}} {{- end }}

Using Named Templates:

# Using include (renders template and allows piping) metadata: labels: {{- include "myapp.labels" . | nindent 4 }} # Using template (renders template directly, no piping) metadata: labels: {{ template "myapp.labels" . }} # Passing additional context name: {{ include "myapp.fullnameWithSuffix" (list . "worker") }}

Value Validation

Validate required values and fail fast:

{{- if not .Values.image.repository }} {{- fail "image.repository is required" }} {{- end }} {{- if and .Values.persistence.enabled (not .Values.persistence.size) }} {{- fail "persistence.size must be set when persistence is enabled" }} {{- end }} {{- if not (or (eq .Values.service.type "ClusterIP") (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) }} {{- fail (printf "Invalid service.type: %s. Must be ClusterIP, NodePort, or LoadBalancer" .Values.service.type) }} {{- end }} # Kubernetes version validation {{- if semverCompare "<1.24.0" .Capabilities.KubeVersion.Version }} {{- fail "This chart requires Kubernetes 1.24 or higher" }} {{- end }}

Multi-Value File Strategy

Organize values for different scenarios:

chart/ ├── values.yaml # Base defaults ├── values-production.yaml # Production overrides ├── values-staging.yaml # Staging overrides ├── values-development.yaml # Development overrides └── environments/ ├── prod-us-west.yaml # Specific deployments ├── prod-eu-central.yaml └── staging-dev-team.yaml

values.yaml (Base):

replicaCount: 2 image: repository: company/myapp tag: "" pullPolicy: IfNotPresent resources: limits: cpu: 1000m memory: 512Mi requests: cpu: 250m memory: 256Mi autoscaling: enabled: false

values-production.yaml:

replicaCount: 5 image: pullPolicy: IfNotPresent resources: limits: cpu: 2000m memory: 1Gi requests: cpu: 500m memory: 512Mi autoscaling: enabled: true minReplicas: 5 maxReplicas: 20 targetCPUUtilizationPercentage: 70 ingress: enabled: true annotations: cert-manager.io/cluster-issuer: letsencrypt-prod hosts: - host: api.company.com paths: - path: / pathType: Prefix tls: - secretName: api-tls hosts: - api.company.com monitoring: enabled: true serviceMonitor: enabled: true

environments/prod-us-west.yaml:

region: us-west-2 nodeSelector: topology.kubernetes.io/region: us-west-2 affinity: podAntiAffinity: requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: app: myapp topologyKey: kubernetes.io/hostname ingress: hosts: - host: api-us.company.com

Deploying with Multiple Value Files:

# Development helm install myapp ./chart -f values-development.yaml # Staging helm install myapp ./chart -f values-staging.yaml # Production with region-specific config helm install myapp ./chart \ -f values-production.yaml \ -f environments/prod-us-west.yaml \ --namespace production # Override specific values via command line helm install myapp ./chart \ -f values-production.yaml \ --set image.tag=v2.1.0 \ --set replicaCount=10

Templating Best Practices

Use toYaml for Complex Structures:

# Bad - error-prone manual templating resources: limits: cpu: {{ .Values.resources.limits.cpu }} memory: {{ .Values.resources.limits.memory }} requests: cpu: {{ .Values.resources.requests.cpu }} memory: {{ .Values.resources.requests.memory }} # Good - clean and maintainable resources: {{- toYaml .Values.resources | nindent 2 }}

Fail Fast with Validation:

{{- if not .Values.required.databaseHost }} {{- fail "Database host must be specified" }} {{- end }}

Use Descriptive Helper Names:

# Bad {{- define "h" -}} ... {{- end }} # Good {{- define "myapp.databaseConnectionString" -}} ... {{- end }}

Leverage Context Variables in Loops:

{{- $root := . -}} {{- range .Values.services }} name: {{ include "myapp.fullname" $root }}-{{ .name }} {{- end }}

Comment Complex Logic:

{{- /* Calculate replica count based on environment and autoscaling configuration. If autoscaling is enabled, use minReplicas. Otherwise, use configured replicaCount. */ -}} {{- $replicas := .Values.replicaCount -}} {{- if .Values.autoscaling.enabled -}} {{- $replicas = .Values.autoscaling.minReplicas -}} {{- end -}} replicas: {{ $replicas }}

Common Pitfalls

Improper Indentation: YAML is whitespace-sensitive. Always use nindent with the correct number:

# Wrong - will break YAML labels: {{ include "myapp.labels" . }} # Correct labels: {{- include "myapp.labels" . | nindent 2 }}

Missing Whitespace Control: Forgetting - creates empty lines:

# Creates empty line {{- if .Values.enabled }} enabled: true {{ end }} # Clean output {{- if .Values.enabled }} enabled: true {{- end }}

Using Template Instead of Include: Use include when piping:

# Wrong - cannot pipe template labels: {{ template "myapp.labels" . | nindent 2 }} # Correct labels: {{- include "myapp.labels" . | nindent 2 }}

Losing Context in Loops: Remember to use $ or pass context:

# Wrong - .Release is undefined in loop {{- range .Values.services }} name: {{ .Release.Name }}-{{ .name }} # Error! {{- end }} # Correct {{- $root := . -}} {{- range .Values.services }} name: {{ $root.Release.Name }}-{{ .name }} {{- end }}

Not Quoting String Values: Some values must be quoted:

# Wrong - numeric strings may be misinterpreted port: {{ .Values.port }} # "8080" becomes 8080 # Correct port: {{ .Values.port | quote }} # "8080" stays "8080"

Key Takeaways