devops

Helm Chart Best Practices: Structure, Values, and Testing (2026)

Production Helm chart patterns for Kubernetes. Library charts, schema validation, helmfile, CI/CD integration with GitHub Actions.

July 4, 2026·8 min read·
#helm#kubernetes#charts#devops

Introduction

Helm is the package manager for Kubernetes, but poorly structured charts cause more pain than they solve. Hardcoded values, untestable templates, and a single massive values.yaml are all too common. After deploying 100+ services with Helm in production, we've distilled the patterns that actually work.

These best practices will save you from 3 AM debugging sessions caused by a stray whitespace in a template. They cover chart structure, dependency management, validation, testing, and production CI/CD — everything you need to ship Helm charts confidently.

Chart Structure

A well-organized chart follows a standard layout that any team member can navigate:

mychart/
  Chart.yaml
  values.yaml
  values.schema.json   # Validate user input
  templates/
    _helpers.tpl       # Reusable template functions
    deployment.yaml
    service.yaml
    ingress.yaml
    hpa.yaml
    NOTES.txt
  tests/
    deployment_test.yaml
  charts/              # Subcharts (managed by helm dependency)

Chart.yaml Essentials

Every Chart.yaml must declare its metadata clearly:

apiVersion: v2
name: api-server
description: REST API service for MyApp
type: application
version: 1.2.0          # Chart version (SemVer)
appVersion: "3.4.1"     # Application version
kubeVersion: ">=1.28.0"
maintainers:
  - name: Platform Team
    email: platform@company.com

Key rules: Always use apiVersion: v2 (Helm 3+), version the chart independently of the app, and specify kubeVersion to prevent installs on incompatible clusters.

Values Validation

Add values.schema.json to catch misconfigurations before they hit the cluster:

{
  "$schema": "https://json-schema.org/draft-07/schema",
  "properties": {
    "replicaCount": {
      "type": "integer",
      "minimum": 1,
      "maximum": 100
    },
    "image": {
      "properties": {
        "repository": {"type": "string", "pattern": "^[a-z0-9./-]+$"},
        "tag": {"type": "string", "minLength": 1}
      },
      "required": ["repository", "tag"]
    }
  },
  "required": ["replicaCount", "image"]
}

Helm validates this schema before rendering any template. A missing required field fails immediately with a clear error message — far better than a cryptic template panic.

_helpers.tpl Patterns

Standardize labels and selectors across all templates:

{{- define "mychart.labels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Use Named Templates for Repeated Blocks

Resource blocks that repeat across templates (Deployment, StatefulSet, Job) should be extracted:

{{- define "mychart.resources" -}}
resources:
  requests:
    cpu: {{ .Values.resources.requests.cpu }}
    memory: {{ .Values.resources.requests.memory }}
  limits:
    cpu: {{ .Values.resources.limits.cpu | default .Values.resources.requests.cpu }}
    memory: {{ .Values.resources.limits.memory | default .Values.resources.requests.memory }}
{{- end }}

This keeps your templates DRY and makes resource changes propagate everywhere automatically.

Managing Chart Dependencies

Real applications depend on other charts — databases, message queues, monitoring agents. Declare them in Chart.yaml:

dependencies:
  - name: postgresql
    version: "~12.0"
    repository: "https://charts.bitnami.com/bitnami"
    condition: postgresql.enabled
    alias: db
  - name: redis
    version: "~18.0"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled

After updating the dependency list, run:

helm dependency update ./mychart

This downloads the charts into charts/ and generates a Chart.lock file. Commit that lock file — it pins versions for reproducible builds.

Conditionals and Tags

Use conditions to enable/disable subcharts per environment:

# values.yaml
postgresql:
  enabled: true
redis:
  enabled: false  # Disabled in dev, use in-memory cache instead

For groups of related subcharts, use tags:

dependencies:
  - name: prometheus
    tags: ["monitoring"]
  - name: grafana
    tags: ["monitoring"]

Then toggle the entire group with one flag:

helm install release ./mychart --set tags.monitoring=false

Library Charts

For shared templates used across multiple services, create a library chart:

# Chart.yaml (library chart)
apiVersion: v2
name: my-lib
version: 1.0.0
type: library

Library charts have no templates of their own — they only provide _helpers.tpl that other charts include. This is perfect for standardization across 50+ microservices without copy-pasting.

Testing Charts

Helm Test Hooks

# tests/deployment_test.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "mychart.fullname" . }}-test"
  annotations:
    "helm.sh/hook": test
spec:
  containers:
  - name: curl
    image: curlimages/curl:latest
    command: ['curl', 'http://{{ include "mychart.fullname" . }}:{{ .Values.service.port }}/health']
  restartPolicy: Never

Run tests:

helm install myapp ./mychart
helm test myapp

Unit Testing with helm-unittest

helm plugin install https://github.com/helm-unittest/helm-unittest
# tests/deployment_test.yaml
suite: deployment
templates:
  - deployment.yaml
tests:
  - it: should set correct replica count
    set:
      replicaCount: 5
    asserts:
      - equal:
          path: spec.replicas
          value: 5
  - it: should use correct image tag
    set:
      image.tag: v2.0
    asserts:
      - matchRegex:
          path: spec.template.spec.containers[0].image
          pattern: ":v2.0$"

Run all tests:

helm unittest ./mychart

This catches regressions before they reach a cluster. Run it in every Pull Request.

Values Management Patterns

Environment-Specific Overrides

Structure your values files by environment:

values/
  values.yaml           # defaults
  values-production.yaml
  values-staging.yaml
  values-dev.yaml

Install with targeted overrides:

helm upgrade --install myapp ./mychart   -f values/values.yaml   -f values/values-production.yaml   --set image.tag=v1.5.0

Helm merges these in order — later files override earlier ones.

Global Values

Share values across parent chart and all subcharts using global:

# parent values.yaml
global:
  environment: production
  dnsDomain: example.com

service:
  port: 8080

Any subchart can access {{ .Values.global.environment }}. This is how you propagate cluster-wide settings without repeating them.

CI/CD Integration

Prevent bad charts from ever reaching production:

# .github/workflows/helm-lint.yml
name: Helm CI
on: [pull_request]
jobs:
  lint-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Helm lint
        run: helm lint ./charts/*
      - name: Helm unit tests
        run: |
          helm plugin install https://github.com/helm-unittest/helm-unittest
          helm unittest ./charts/*
      - name: Install in kind cluster
        run: |
          kind create cluster
          helm install test-release ./charts/api-server --wait
          helm test test-release

Chart Signing and OCI Distribution

Supply chain security starts with your Helm charts. Signing ensures that the chart hasn't been tampered with between packaging and installation.

Signing Charts with GPG

Generate a signing key and package with a signature:

# Generate GPG key (one-time)
gpg --full-generate-key

# Export the public key
gpg --armor --export devops@company.com > helm-public-key.asc

# Package and sign
helm package ./mychart --sign \
  --key 'DevOps Team' \
  --keyring ~/.gnupg/secring.gpg

This produces two files: mychart-1.2.0.tgz (the chart) and mychart-1.2.0.tgz.prov (the provenance file). Distribute both.

Verifying on Install

# Import the public key
gpg --import helm-public-key.asc

# Verify during install
helm install myapp ./mychart-1.2.0.tgz --verify

If the signature doesn't match or the key is unknown, Helm refuses to install. In CI/CD, automate verification before any non-development deployment.

OCI Registry Distribution

Store charts in an OCI-compliant registry for access control and immutability:

# Log in to your registry
helm registry login registry.example.com

# Push the chart
helm push mychart-1.2.0.tgz oci://registry.example.com/charts

# Install from OCI
helm install myapp oci://registry.example.com/charts/mychart --version 1.2.0

AWS ECR, GCP Artifact Registry, Azure Container Registry, and Harbor all support OCI Helm charts. This replaces the need for ChartMuseum or static storage buckets.

Advanced CI/CD Patterns

Extend the basic CI pipeline with release automation:

# .github/workflows/helm-release.yml
name: Helm Release
on:
  push:
    branches: [main]
    paths: ['charts/**', 'Chart.yaml']

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Helm lint
        run: helm lint ./charts/*
      - name: Helm unit tests
        run: helm unittest ./charts/*
      - name: Package and sign
        run: |
          helm package ./charts/api-server
          helm package ./charts/worker
      - name: Push to OCI registry
        run: |
          helm push api-server-*.tgz oci://${{ secrets.REGISTRY }}/charts
          helm push worker-*.tgz oci://${{ secrets.REGISTRY }}/charts
      - name: Update ArgoCD image tags
        run: |
          sed -i "s/tag:.*/tag: ${{ github.sha }}/" deploy/argocd/*.yaml
          git commit -am "chore: bump image tag to ${{ github.sha }}"
          git push

This pattern — lint, test, sign, push, and update deployment manifests — is the gold standard for Helm-based delivery pipelines.

Helmfile for Multi-Environment Deployments

When you manage dozens of releases across multiple clusters, raw helm commands become unmanageable. Enter Helmfile — a declarative layer that describes your entire Helm state:

# helmfile.yaml
repositories:
  - name: bitnami
    url: https://charts.bitnami.com/bitnami

releases:
  - name: api-server
    chart: ./charts/api-server
    values:
      - values/common.yaml
      - values/{{ .Environment.Name }}/api-server.yaml
    set:
      - name: image.tag
        value: "{{ .Values.tag }}"

  - name: postgresql
    chart: bitnami/postgresql
    version: 12.x
    namespace: database

environments:
  production:
    values:
      - env/production.yaml
  staging:
    values:
      - env/staging.yaml

Apply the entire stack:

helmfile -e production sync

Helmfile tracks release order, handles dependencies, and runs helm diff before applying. It's the missing orchestration layer for Helm at scale.

ArgoCD Integration with Helm

ArgoCD natively supports Helm charts. Point it to a chart in a Git repository:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: api-server
spec:
  source:
    repoURL: https://github.com/company/charts.git
    path: charts/api-server
    targetRevision: main
    helm:
      valueFiles:
        - values/production.yaml
      parameters:
        - name: image.tag
          value: v1.5.0
  destination:
    server: https://kubernetes.default.svc
    namespace: production

ArgoCD renders the Helm template during sync, compares it against live state, and applies only the diff. Enable prune to remove resources removed from the chart:

argocd app set api-server --sync-policy automated --auto-prune

This gives you GitOps workflows with Helm's template power — the best of both worlds.

Conclusion

Good Helm charts are self-documenting, validated, and tested. Use values.schema.json to catch errors early, _helpers.tpl for consistency, and CI/CD to enforce quality. Adopt OCI registries for chart distribution, sign your releases, and unit-test every template. For multi-cluster setups, layer Helmfile or ArgoCD on top.

Start today: add values.schema.json to your most-used chart, write one unit test, and enable --atomic on all production deployments. That's three changes that will immediately reduce deployment failures.

#helm#kubernetes#charts#devops
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →