Kubernetes Lesson 55 – Helm Charts | Dataplexa
Advanced Workloads & Operations · Lesson 47

Helm Charts: Writing Your Own

Lesson 46 covered using existing Helm charts — adding repos, running helm install, and customising with values files. This lesson goes one level deeper: writing a chart from scratch, understanding the template engine, building reusable helpers, and structuring values for multiple environments.

Chart Structure

A Helm chart is a directory with a specific layout. Every file has a defined purpose — understanding the layout is the first step to writing charts that behave predictably.

my-app/
├── Chart.yaml          # Chart metadata: name, version, appVersion, description, dependencies
├── values.yaml         # Default values — overridden per environment or at install time
├── values-staging.yaml # Environment-specific overrides (not loaded automatically — you pass -f)
├── values-prod.yaml
├── templates/          # Go template files — rendered into Kubernetes manifests
│   ├── _helpers.tpl    # Named templates (partials) — NOT rendered directly, only called
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── hpa.yaml
│   └── NOTES.txt       # Printed to stdout after helm install — usage instructions
├── charts/             # Dependency charts (subcharts) — populated by helm dependency update
└── .helmignore         # Files to exclude from the chart package (like .gitignore)

# Create the scaffolding for a new chart:
helm create my-app
# Generates a working chart with a sample Deployment, Service, Ingress, HPA, and ServiceAccount
# Start here, then strip out what you don't need and customise what you do

Chart.yaml and values.yaml

The scenario: You're packaging the payment-api application as a Helm chart that can be deployed to staging and production with different configurations — different image tags, replica counts, resource limits, and feature flags — without maintaining separate sets of manifests.

# Chart.yaml — chart metadata
apiVersion: v2                  # v2: Helm 3 format (v1 was Helm 2)
name: payment-api
description: Payment API service for dataplexa platform
type: application               # application: deployable. library: helpers only, not deployable
version: 1.4.2                  # Chart version — bump this when you change the chart
                                # Follows semver: major.minor.patch
appVersion: "3.0.0"             # Application version — what you're deploying (informational)
                                # Appears in 'helm list' output — helpful for audit
keywords:
  - payments
  - api
maintainers:
  - name: Platform Team
    email: platform@company.com
dependencies:                   # Charts this chart depends on (sub-charts)
  - name: postgresql
    version: "12.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled  # Only install if values.postgresql.enabled is true
# values.yaml — default values (safe for development)
replicaCount: 2

image:
  repository: 123456789012.dkr.ecr.us-east-1.amazonaws.com/payment-api
  tag: ""                       # Empty string: templates will use .Chart.AppVersion as fallback
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: false                # Feature flags control optional resources
  host: ""
  tlsSecretName: ""

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

postgresql:
  enabled: false                # Subchart toggle — don't deploy postgres by default

config:
  logLevel: info
  featureFlags:
    newCheckoutFlow: false
    fraudDetectionV2: false

podAnnotations: {}              # Empty map — templates check if non-empty before rendering
nodeSelector: {}
tolerations: []
affinity: {}

Chart design principles

values.yaml should be safe for development — The defaults represent the minimal configuration needed to run the application locally or in a dev environment. Production values go in a separate values-prod.yaml that overrides only what changes. This way, helm install my-app ./my-app just works without additional flags.

Feature flags for optional resources — Use boolean toggles like ingress.enabled and autoscaling.enabled to control whether optional resources are rendered. A single chart then serves both the simple dev deployment (no ingress, no HPA) and the full production deployment. The condition field in Chart.yaml does the same for subcharts.

Writing Templates

Templates are Go template files that Helm renders into Kubernetes manifests. The template engine provides values from .Values, chart metadata from .Chart, and release information from .Release. The {{- }} syntax strips whitespace — important for producing clean YAML.

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "payment-api.fullname" . }}      # Named template from _helpers.tpl
  namespace: {{ .Release.Namespace }}               # Namespace the chart is installed into
  labels:
    {{- include "payment-api.labels" . | nindent 4 }}
    # include: calls a named template and returns its output as a string
    # nindent 4: adds a newline then indents 4 spaces — critical for valid YAML
spec:
  {{- if not .Values.autoscaling.enabled }}          # Conditional: only set replicas if no HPA
  replicas: {{ .Values.replicaCount }}               # HPA manages replicas when enabled
  {{- end }}
  selector:
    matchLabels:
      {{- include "payment-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "payment-api.selectorLabels" . | nindent 8 }}
      {{- with .Values.podAnnotations }}              # 'with' changes the dot scope to the value
      annotations:                                    # and skips the block if the value is empty/nil
        {{- toYaml . | nindent 8 }}                  # toYaml: converts the map to YAML string
      {{- end }}
    spec:
      containers:
        - name: payment-api
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          # default: returns the fallback if the value is empty
          # So if image.tag is "", use Chart.AppVersion ("3.0.0")
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          env:
            - name: LOG_LEVEL
              value: {{ .Values.config.logLevel | quote }}
              # quote: wraps the value in double quotes — important for string values
              # that might look like numbers or booleans (e.g. "true", "80")
            - name: FEATURE_NEW_CHECKOUT
              value: {{ .Values.config.featureFlags.newCheckoutFlow | toString | quote }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
# templates/ingress.yaml
{{- if .Values.ingress.enabled }}           # The entire file is gated — nothing rendered if disabled
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "payment-api.fullname" . }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "payment-api.labels" . | nindent 4 }}
spec:
  {{- if .Values.ingress.tlsSecretName }}   # Only render TLS block if a secret name is provided
  tls:
    - hosts:
        - {{ .Values.ingress.host | quote }}
      secretName: {{ .Values.ingress.tlsSecretName | quote }}
  {{- end }}
  rules:
    - host: {{ .Values.ingress.host | quote }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "payment-api.fullname" . }}
                port:
                  number: {{ .Values.service.port }}
{{- end }}
# Preview what the templates render to before installing:
$ helm template payment-api ./payment-api \
  --set ingress.enabled=true \
  --set ingress.host=api.company.com
---
# Source: payment-api/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
  namespace: default
  labels:
    helm.sh/chart: payment-api-1.4.2
    app.kubernetes.io/name: payment-api
    app.kubernetes.io/instance: payment-api
    app.kubernetes.io/version: "3.0.0"
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 2
  [...]
  containers:
    - name: payment-api
      image: "123456789012.dkr.ecr.us-east-1.amazonaws.com/payment-api:3.0.0"
---
# Source: payment-api/templates/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
[...]   ← ingress rendered because --set ingress.enabled=true ✓

What just happened?

helm template is your development loop — Run it before every helm install to preview exactly what Kubernetes manifests will be generated. It catches template errors, missing values, and unexpected rendering before anything touches the cluster. In CI, run helm template | kubectl apply --dry-run=server -f - to also validate against the API server schema.

nindent vs indentnindent N prepends a newline before indenting — use it after a {{- expression that strips the preceding newline. indent N does not prepend a newline. Getting this wrong produces invalid YAML — the YAML indentation must be exact. helm template catches these errors immediately.

_helpers.tpl: Named Templates and Reusable Partials

Files starting with _ are not rendered directly — they define named templates (partials) that other templates call with include. This is where you centralise the label set, name generation logic, and any repeated YAML snippets.

# templates/_helpers.tpl

{{/*
Expand the name of the chart.
*/}}
{{- define "payment-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
# trunc 63: Kubernetes names max at 63 chars
# trimSuffix "-": clean up any trailing hyphens after truncation

{{/*
Create a default fully qualified app name.
Release name + chart name, truncated to 63 characters.
If release name already contains the chart name, use only the release name.
*/}}
{{- define "payment-api.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Standard labels — applied to every resource for consistent identification.
These follow Kubernetes recommended labels (app.kubernetes.io/* namespace).
*/}}
{{- define "payment-api.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }}
{{ include "payment-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels — used in matchLabels (must be immutable after first deploy)
Keep this minimal: only labels that uniquely identify the Pod
*/}}
{{- define "payment-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "payment-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

What just happened?

Selector labels must never change after first deploy — The selectorLabels template is used in both matchLabels (the Deployment selector) and the Pod template labels. Kubernetes treats selector labels as immutable on Deployments — if you change them, the upgrade fails. Keep selector labels minimal and stable. The fuller labels template (which adds version, chart version, etc.) is used in metadata.labels where changes are safe.

fullname vs nameOverride vs fullnameOverride — By default, Helm names resources {release-name}-{chart-name}. If the release name already contains the chart name (e.g., release payment-api installing chart payment-api), the fullname helper avoids the redundant payment-api-payment-api duplication. fullnameOverride lets users completely control the resource names when they install the chart.

Multi-Environment Values Files

The -f flag layers additional values files on top of the defaults. Values are deep-merged — only keys you specify are overridden; the rest come from values.yaml. Multiple -f flags are applied in order, left to right.

# values-prod.yaml — only the keys that differ from values.yaml defaults
replicaCount: 5              # Higher replicas in prod

image:
  tag: "3.0.0"               # Pin a specific tag in prod (never use latest)

ingress:
  enabled: true              # Enable ingress in prod
  host: api.company.com
  tlsSecretName: api-tls-cert

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: 1
    memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 5
  maxReplicas: 20

config:
  logLevel: warn             # Less verbose in prod
  featureFlags:
    newCheckoutFlow: true    # Feature enabled in prod
    fraudDetectionV2: true
# Install or upgrade with environment-specific values
helm upgrade --install payment-api ./payment-api \
  --namespace payments \
  --create-namespace \
  -f values-prod.yaml \
  --set image.tag=3.1.0 \
  --atomic \
  --timeout 5m
# --upgrade --install: create if not exists, upgrade if exists (idempotent)
# -f values-prod.yaml: overlay prod values on top of defaults
# --set image.tag: --set overrides -f overrides values.yaml (rightmost wins)
# --atomic: rollback automatically if the upgrade fails (pods don't become Ready)
# --timeout: how long to wait for pods to become Ready before declaring failure

# Dry run — see exactly what would change without touching the cluster:
helm upgrade --install payment-api ./payment-api \
  -f values-prod.yaml \
  --set image.tag=3.1.0 \
  --dry-run

# Diff against what's currently deployed (requires helm-diff plugin):
helm diff upgrade payment-api ./payment-api \
  -f values-prod.yaml \
  --set image.tag=3.1.0
$ helm upgrade --install payment-api ./payment-api \
  --namespace payments --create-namespace \
  -f values-prod.yaml --set image.tag=3.1.0 \
  --atomic --timeout 5m
Release "payment-api" does not exist. Installing it now.
NAME: payment-api
LAST DEPLOYED: Mon Mar 10 14:44:01 2025
NAMESPACE: payments
STATUS: deployed
REVISION: 1

$ helm list -n payments
NAME          NAMESPACE   REVISION   UPDATED                  STATUS     CHART              APP VERSION
payment-api   payments    1          2025-03-10 14:44:01      deployed   payment-api-1.4.2  3.0.0

$ helm diff upgrade payment-api ./payment-api -f values-prod.yaml --set image.tag=3.2.0
  Deployment: payment-api
-   image: "...payment-api:3.1.0"
+   image: "...payment-api:3.2.0"   ← only the image tag changes — nothing else ✓

Teacher's Note: Helm in GitOps — the values-as-config pattern

The most effective way to use Helm in a GitOps workflow: commit the chart (or a Chart.yaml referencing a chart version from a registry) and the values files to Git. The CI/CD pipeline runs helm upgrade --install with the appropriate values file on every merge to main. The values files become the source of truth for what's deployed where — reviewable, versioned, and rollback-able via Git history.

One thing to avoid: deeply nesting values. If you have config.database.connection.pool.max in your values, anyone reading the chart needs to trace through four levels of nesting to understand what it does. Keep values shallow and well-named. A flat dbPoolMax: 10 is harder to accidentally override with a --set typo, and clearer to review in a PR.

Use helm lint ./my-chart in CI to catch template errors and schema violations before they reach the cluster. Pair it with helm template | kubeval or helm template | kubectl apply --dry-run=server -f - for API-server-level validation.

Practice Questions

1. In a Helm chart's templates/ directory, what file conventionally holds named templates (partials) that are called with include but are never rendered directly into manifests?



2. Which Helm command renders a chart's templates to standard output — letting you preview the generated Kubernetes manifests without installing anything to the cluster?



3. Which helm upgrade flag automatically rolls back to the previous release if the upgraded Pods do not become Ready within the timeout period?



Quiz

1. Helm templates often use | nindent 4 rather than | indent 4. What is the difference, and when does it matter?


2. Why does _helpers.tpl define a minimal selectorLabels template separately from the fuller labels template?


3. You run helm upgrade my-app ./chart -f values-prod.yaml --set image.tag=3.2.0. How are the three sources of values (values.yaml, values-prod.yaml, --set) merged?


Up Next · Lesson 48

Helm Hooks and Tests

Hooks let you run Jobs at specific points in the release lifecycle — before install, after upgrade, before delete. Tests are a special hook type that verifies a release is working correctly. Together they enable database migrations, smoke tests, and pre-flight checks as first-class chart features.