Published on

Helm Charts in Production — Templating, Testing, and Chart Promotion Strategies

Authors

Introduction

Helm is the package manager for Kubernetes. A well-designed chart is reusable, testable, and deployable across dev, staging, and production with minimal configuration drift. A poorly designed chart becomes a maintenance nightmare—filled with conditional logic, hard-coded values, and charts that work in dev but fail mysteriously in prod. This post covers chart structure, templating best practices, testing strategies, and chart promotion pipelines for production deployments.

Chart Structure and Best Practices

A production-grade Helm chart follows a consistent structure that scales with complexity.

my-app-chart/
├── Chart.yaml
├── values.yaml
├── values-prod.yaml
├── values-staging.yaml
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── serviceaccount.yaml
│   ├── hpa.yaml
│   ├── pdb.yaml
│   ├── networkpolicy.yaml
│   └── tests/
│       ├── test-deployment.yaml
│       └── test-connection.yaml
├── charts/
│   └── postgresql/ (if bundling dependencies)
├── hooks/
│   ├── pre-install.yaml
│   ├── post-install.yaml
│   ├── pre-upgrade.yaml
│   └── post-upgrade.yaml
└── README.md

Chart.yaml defines metadata:

apiVersion: v2
name: my-app
description: Production application
type: application
version: 1.5.2
appVersion: "2.3.1"
maintainers:
- name: Platform Team
  email: platform@example.com
dependencies:
- name: postgresql
  version: "14.x"
  repository: "oci://registry-1.docker.io/bitnamicharts"
  condition: postgresql.enabled
keywords:
- api
- production
- microservices

values.yaml Design with Sensible Defaults

The base values.yaml must be production-safe. New teams deploying the chart should get secure, performant defaults without additional configuration.

replicaCount: 3

image:
  repository: my-app
  pullPolicy: IfNotPresent
  tag: "2.3.1"

imagePullSecrets:
- name: registry-credentials

nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  annotations:
    iam.gke.io/gcp-service-account: "my-app@my-project.iam.gserviceaccount.com"
  name: ""

podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "8080"
  prometheus.io/path: "/metrics"

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1000
  fsGroup: 1000
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
    - ALL

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

livenessProbe:
  httpGet:
    path: /health
    port: http
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 5
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /ready
    port: http
  initialDelaySeconds: 10
  periodSeconds: 5
  timeoutSeconds: 3
  failureThreshold: 2

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 75
  targetMemoryUtilizationPercentage: 80

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
    - weight: 100
      podAffinityTerm:
        labelSelector:
          matchExpressions:
          - key: app.kubernetes.io/name
            operator: In
            values:
            - my-app
        topologyKey: kubernetes.io/hostname

persistence:
  enabled: false
  size: 10Gi
  storageClassName: "fast-ssd"

serviceMonitor:
  enabled: true
  interval: 30s
  scrapeTimeout: 10s

postgresql:
  enabled: false
  auth:
    username: myapp
    password: changeme
    database: myappdb

Named Templates and Helpers

Define reusable template fragments in _helpers.tpl:

{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
*/}}
{{- define "my-app.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 }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "my-app.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.chart" . }}
{{ include "my-app.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Use helpers in templates:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      {{- with .Values.podAnnotations }}
      annotations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "my-app.fullname" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
      - name: {{ .Chart.Name }}
        securityContext:
          {{- toYaml .Values.securityContext | nindent 12 }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - name: http
          containerPort: 8080
          protocol: TCP
        livenessProbe:
          {{- toYaml .Values.livenessProbe | nindent 12 }}
        readinessProbe:
          {{- toYaml .Values.readinessProbe | nindent 12 }}
        resources:
          {{- toYaml .Values.resources | nindent 12 }}

Pre/Post Hooks and Lifecycle Management

Helm hooks execute custom logic during installation or upgrades. Common use cases: database migrations, cache invalidation, data backups.

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "my-app.fullname" . }}-db-migrate
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
  annotations:
    helm.sh/hook: pre-upgrade
    helm.sh/hook-weight: "0"
    helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
spec:
  backoffLimit: 3
  template:
    spec:
      serviceAccountName: {{ include "my-app.fullname" . }}
      restartPolicy: Never
      containers:
      - name: migrate
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        command:
        - /app/migrate.sh
        env:
        - name: DB_HOST
          value: {{ .Values.database.host }}
        - name: DB_PORT
          value: {{ .Values.database.port | quote }}
        - name: DB_USER
          valueFrom:
            secretKeyRef:
              name: {{ include "my-app.fullname" . }}-db
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ include "my-app.fullname" . }}-db
              key: password

Hook weight controls execution order. Negative weights run before positive:

annotations:
  helm.sh/hook: pre-upgrade
  helm.sh/hook-weight: "-5"  # Runs first
---
annotations:
  helm.sh/hook: pre-upgrade
  helm.sh/hook-weight: "0"   # Runs second
---
annotations:
  helm.sh/hook: pre-upgrade
  helm.sh/hook-weight: "5"   # Runs last

Testing with helm-unittest

helm-unittest validates chart rendering and values without deploying:

helm plugin install https://github.com/helm-unittest/helm-unittest.git

Create tests/deployment_test.yaml:

suite: test deployment
templates:
  - deployment.yaml
tests:
  - it: should render deployment with default values
    asserts:
      - isKind:
          of: Deployment
      - equal:
          path: metadata.name
          value: RELEASE-NAME-my-app
      - equal:
          path: spec.replicas
          value: 3

  - it: should scale replicas when autoscaling disabled
    set:
      autoscaling.enabled: false
      replicaCount: 5
    asserts:
      - equal:
          path: spec.replicas
          value: 5

  - it: should set security context
    asserts:
      - equal:
          path: spec.template.spec.securityContext.runAsNonRoot
          value: true
      - equal:
          path: spec.template.spec.containers[0].securityContext.allowPrivilegeEscalation
          value: false

  - it: should render HPA when autoscaling enabled
    set:
      autoscaling.enabled: true
    templates:
      - hpa.yaml
    asserts:
      - isKind:
          of: HorizontalPodAutoscaler
      - equal:
          path: spec.minReplicas
          value: 3
      - equal:
          path: spec.maxReplicas
          value: 10

Run tests:

helm unittest ./my-app-chart

Chart Promotion: Dev → Staging → Prod

Use separate values files or Helmfile to manage environment-specific configurations:

helmfile.yaml:

helmDefaults:
  atomic: true
  wait: true
  timeout: 600
  recreatePods: false

releases:
  - name: my-app
    namespace: production
    chart: ./my-app-chart
    version: 1.5.2
    values:
    - ./values.yaml
    - ./values-prod.yaml
    hooks:
    - events: ["presync"]
      showlogs: true
      command: "sh"
      args: ["pre-upgrade-checks.sh"]
    - events: ["postsync"]
      showlogs: true
      command: "sh"
      args: ["post-upgrade-validation.sh"]
    secrets:
    - values-prod-secrets.yaml

values-prod.yaml:

replicaCount: 5

image:
  tag: "2.3.1"  # Pin specific version in prod

resources:
  limits:
    cpu: 1500m
    memory: 1Gi
  requests:
    cpu: 500m
    memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 5
  maxReplicas: 50
  targetCPUUtilizationPercentage: 70

persistence:
  enabled: true
  size: 100Gi

postgresql:
  enabled: true
  auth:
    username: produser
  primary:
    persistence:
      enabled: true
      size: 50Gi

Helmfile for Multi-Chart Orchestration

Helmfile manages multiple charts and their dependencies:

helmDefaults:
  atomic: true
  wait: true
  waitForJobs: true
  timeout: 600

repositories:
- name: bitnami
  url: https://charts.bitnami.com/bitnami
- name: prometheus-community
  url: https://prometheus-community.github.io/helm-charts
- name: myrepo
  url: oci://registry.example.com/helm

releases:
  # Infrastructure
  - name: postgres
    namespace: databases
    chart: bitnami/postgresql
    version: 14.0.0
    values:
    - ./values-postgres.yaml

  # Monitoring
  - name: prometheus
    namespace: monitoring
    chart: prometheus-community/prometheus
    version: 25.0.0
    values:
    - ./values-prometheus.yaml

  # Application
  - name: my-app
    namespace: production
    chart: myrepo/my-app
    version: 1.5.2
    values:
    - ./values.yaml
    - ./values-prod.yaml
    depends:
    - databases/postgres
    - monitoring/prometheus

  - name: my-app-worker
    namespace: production
    chart: myrepo/my-app-worker
    version: 1.2.1
    values:
    - ./values-worker.yaml
    depends:
    - production/my-app

Deploy with:

helmfile -f helmfile.yaml apply

Chart Repository with OCI Registry

Store charts in OCI registries (Docker registries) for version control and immutability:

# Package chart
helm package ./my-app-chart

# Push to OCI registry
helm push my-app-chart-1.5.2.tgz oci://registry.example.com/helm

# Pull and deploy
helm upgrade --install my-app oci://registry.example.com/helm/my-app --version 1.5.2

github/workflows/chart-release.yaml:

name: Release Helm Chart
on:
  push:
    tags:
      - 'v*'
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: azure/setup-helm@v3
    - run: helm package ./my-app-chart
    - run: |
        helm registry login -u ${{ secrets.REGISTRY_USER }} \
          -p ${{ secrets.REGISTRY_PASSWORD }} \
          registry.example.com
        helm push my-app-chart-*.tgz oci://registry.example.com/helm

Checklist

  • Chart follows standard directory structure
  • values.yaml has secure, production-ready defaults
  • All templates use named helpers from _helpers.tpl
  • Security context enforces non-root, read-only filesystem
  • Probes configured with reasonable timeouts and thresholds
  • Chart passes helm lint without warnings
  • Unit tests cover all major branches and edge cases
  • Pre/post hooks for migrations and health checks
  • Environment-specific values files (dev, staging, prod)
  • Charts versioned and stored in OCI registry
  • Documentation (README.md) explains customization points
  • Chart promotion tested across all environments before prod

Conclusion

A production Helm chart is infrastructure as code—version controlled, tested, and promoted systematically. Invest upfront in sensible defaults, comprehensive tests, and environment promotion pipelines. This discipline prevents configuration drift, reduces deployment failures, and enables teams to deploy with confidence. Helmfile layers on orchestration for multi-chart stacks, making it possible to manage entire platforms reproducibly.