Values and Templating

Learn how Helm values work and how Go templates turn your charts into dynamic, reusable Kubernetes manifests.

8 min read

Values and Templating

In the previous tutorial, we explored every file in a Helm chart. Now it's time to learn the language that makes charts dynamic — Go templates. If you've used Jinja2 (Python) or Handlebars (JavaScript), you'll pick this up fast. If not, don't worry — it's simpler than it looks.

Go Template Basics

Every template expression lives inside double curly braces:

# This is a static Kubernetes manifest:
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-config

# This is a Helm template:
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-config

When you install with helm install prod ./my-chart, the {{ .Release.Name }} becomes prod, and you get:

apiVersion: v1
kind: ConfigMap
metadata:
  name: prod-config

That's the core idea. Everything inside {{ }} gets evaluated, everything outside is passed through as-is.

The Dot — Your Best Friend

In Go templates, the dot (.) refers to the current scope — the top-level data object. When Helm renders a template, it passes in a root object with several fields:

# The root object looks conceptually like this:
.Release     # Info about the current release
.Values      # Merged values from values.yaml + overrides
.Chart       # Contents of Chart.yaml
.Files       # Access to non-template files in the chart
.Capabilities # Info about the Kubernetes cluster
.Template    # Info about the current template file

You access nested properties with dots:

{{ .Values.image.repository }}
{{ .Release.Name }}
{{ .Chart.AppVersion }}

.Values — User Configuration

This is where you'll spend most of your time. Every key in values.yaml is accessible under .Values:

# values.yaml
database:
  host: localhost
  port: 5432
  name: myapp
  credentials:
    username: admin
    password: secret123
# templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-db-config
data:
  DB_HOST: {{ .Values.database.host }}
  DB_PORT: {{ .Values.database.port | quote }}
  DB_NAME: {{ .Values.database.name }}

"Why quote on the port?"

Good catch. Kubernetes ConfigMap values must be strings. A bare 5432 in YAML is interpreted as an integer, which K8s rejects. The quote function wraps it in double quotes: "5432". We'll cover more functions in the next tutorial.

.Release — Install Metadata

# Useful for making resources unique per-release
metadata:
  name: {{ .Release.Name }}-app
  namespace: {{ .Release.Namespace }}
  labels:
    release: {{ .Release.Name }}
    revision: {{ .Release.Revision | quote }}

Full list of .Release fields:

FieldDescriptionExample
.Release.NameRelease name from helm install NAMEprod
.Release.NamespaceNamespace being deployed todefault
.Release.RevisionRevision number (starts at 1)3
.Release.IsInstalltrue on first installtrue
.Release.IsUpgradetrue on helm upgradefalse
.Release.ServiceAlways "Helm"Helm

.Chart — Chart Metadata

Everything from Chart.yaml is available here:

labels:
  chart: {{ .Chart.Name }}-{{ .Chart.Version }}
  app-version: {{ .Chart.AppVersion | quote }}

Note: Chart.yaml keys are title-cased when accessed. So name becomes .Chart.Name, version becomes .Chart.Version.

.Capabilities — Cluster Info

Useful for writing charts that adapt to different Kubernetes versions:

{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1" }}
apiVersion: networking.k8s.io/v1
{{- else }}
apiVersion: networking.k8s.io/v1beta1
{{- end }}
kind: Ingress
# Check the Kubernetes version
{{ .Capabilities.KubeVersion.Major }}
{{ .Capabilities.KubeVersion.Minor }}
{{ .Capabilities.KubeVersion.Version }}

.Files — Accessing Extra Files

You can include non-template files from your chart:

# Suppose you have config/app.conf in your chart root
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-app-config
data:
  app.conf: |-
{{ .Files.Get "config/app.conf" | indent 4 }}

The .Files object also has some handy methods:

# Get file content as string
{{ .Files.Get "config/app.conf" }}

# Get file content as bytes
{{ .Files.GetBytes "bindata/cert.pem" }}

# Glob for multiple files
{{ range $path, $content := .Files.Glob "config/*.conf" }}
  {{ $path }}: |-
{{ $content | indent 4 }}
{{ end }}

# Base64 encode (useful for Secrets)
{{ .Files.Get "certs/tls.crt" | b64enc }}

Note: .Files cannot access files in templates/ or files excluded by .helmignore.

Whitespace Control

This is the one thing that trips up everyone. Look at this template:

metadata:
  labels:
    {{ if .Values.environment }}
    env: {{ .Values.environment }}
    {{ end }}
    app: my-app

If environment is set, the output has blank lines:

metadata:
  labels:

    env: production

    app: my-app

Gross. Fix it with dash-trimming {{- and -}}:

metadata:
  labels:
    {{- if .Values.environment }}
    env: {{ .Values.environment }}
    {{- end }}
    app: my-app

Clean output:

metadata:
  labels:
    env: production
    app: my-app

The rules:

  • {{- trims all whitespace (including newlines) before the tag
  • -}} trims all whitespace after the tag
  • {{ and }} (no dash) leave whitespace alone

"When should I use dashes?"

Use {{- on control structures (if, end, range, etc.) that would leave blank lines. Don't use them on lines that output actual values — you'll eat the indentation and break your YAML.

Variables

You can assign values to variables with :=:

{{- $releaseName := .Release.Name -}}
{{- $fullName := printf "%s-%s" $releaseName .Chart.Name -}}

metadata:
  name: {{ $fullName }}
  labels:
    release: {{ $releaseName }}

Variables are especially useful inside range loops where the dot (.) gets rebound:

# values.yaml
ports:
  - name: http
    port: 80
  - name: https
    port: 443
# Without variable — can't access .Release.Name inside range!
{{- range .Values.ports }}
- name: {{ .name }}       # .name works (dot is rebound to current item)
  port: {{ .port }}
  # {{ .Release.Name }}   # BROKEN — .Release doesn't exist here!
{{- end }}

# With variable — save root context before the loop
{{- $root := . -}}
{{- range .Values.ports }}
- name: {{ .name }}
  port: {{ .port }}
  release: {{ $root.Release.Name }}   # Works!
{{- end }}

There's also a two-variable form for ranges:

{{- range $key, $value := .Values.env }}
  {{ $key }}: {{ $value | quote }}
{{- end }}

Quoting and Type Safety

YAML type coercion is a common pitfall. Here's what catches people:

# values.yaml
port: 8080
enabled: true
version: "1.0"
# In a ConfigMap, all values must be strings
data:
  PORT: {{ .Values.port }}            # Renders as: PORT: 8080 (integer — ERROR!)
  PORT: {{ .Values.port | quote }}    # Renders as: PORT: "8080" (string — correct)

  ENABLED: {{ .Values.enabled }}          # Renders as: ENABLED: true (boolean — ERROR!)
  ENABLED: {{ .Values.enabled | quote }}  # Renders as: ENABLED: "true" (string — correct)

Rule of thumb:

  • In ConfigMaps and environment variables → always quote
  • In resource specs (replicas, ports, etc.) → don't quote (they expect numbers)

Rendering Complex Values with toYaml

Sometimes you want to dump an entire YAML block from values:

# values.yaml
resources:
  limits:
    cpu: 200m
    memory: 256Mi
  requests:
    cpu: 100m
    memory: 128Mi
# templates/deployment.yaml
containers:
  - name: app
    resources:
      {{- toYaml .Values.resources | nindent 6 }}

The toYaml function converts the Go value back to YAML text, and nindent 6 adds a newline and indents everything by 6 spaces. Result:

containers:
  - name: app
    resources:
      limits:
        cpu: 200m
        memory: 256Mi
      requests:
        cpu: 100m
        memory: 128Mi

"What's the difference between indent and nindent?"

  • indent N — adds N spaces to the beginning of each line
  • nindent N — same thing, but also adds a newline before the first line

Use nindent when the template tag is on the same line as a YAML key (the most common case). Use indent when you're already on a new line.

Debugging Templates

When your template produces unexpected output, use helm template to render without installing:

# Render all templates locally
helm template my-release ./my-chart

# Render with custom values
helm template my-release ./my-chart -f custom-values.yaml

# Render a specific template
helm template my-release ./my-chart -s templates/deployment.yaml

# Show all computed values
helm template my-release ./my-chart --debug

There's also a fail function for intentional errors:

{{- if not .Values.image.repository }}
  {{- fail "image.repository is required!" }}
{{- end }}

Quick Reference: Template Syntax

SyntaxPurposeExample
{{ .Values.x }}Output a value{{ .Values.replicaCount }}
{{- ... -}}Trim whitespace{{- if .Values.x }}
{{/* comment */}}Comment (not rendered){{/* TODO */}}
{{ $var := val }}Variable assignment{{ $name := .Chart.Name }}
{{ func arg }}Function call{{ quote .Values.port }}
pipePipeline (same thing)port pipe quote

What's Next?

You now know how to access values, use the built-in objects, handle whitespace, and debug your templates. These are the building blocks for everything that follows.

In the next tutorial, we'll explore template functions and pipelines — the rich library of string, math, list, and dictionary functions that make Helm templates truly powerful.