Developing Helm Charts: Templates, Values, and Testing
Create production-ready Helm charts with Go templates, custom value schemas, and testing using Helm unittest and ct.
Introduction
A Helm chart packages Kubernetes manifests with a templating layer that lets one chart serve multiple environments. Instead of maintaining separate YAML files for dev, staging, and production, you write templates that accept values at deploy time. This separation between configuration and templates is what makes Helm charts reusable and why teams reach for them when Kubernetes deployments start involving multiple environments.
Chart development starts with understanding the directory structure, the Go template syntax, and the values cascading system. From there, you add named templates for consistency, JSON schema validation to catch misconfiguration early, and unit tests to verify template output across different input combinations. Getting these fundamentals right means charts that are easy to understand, safe to deploy, and straightforward to maintain as your infrastructure grows.
This guide walks through creating production-ready Helm charts: directory layout, template functions and Sprig utilities, named templates and helper functions, values schema validation, and testing with the Helm unittest plugin. You will build charts that handle environment-specific configuration cleanly, fail fast when users provide invalid values, and include tests that catch regressions before the chart reaches production.
Chart Directory Structure
Every Helm chart follows a predictable layout. At minimum, you need:
mychart/
├── Chart.yaml # Chart metadata and dependencies
├── values.yaml # Default configuration values
├── values.schema.json # Optional: JSON schema for values validation
├── templates/ # Kubernetes manifest templates
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── _helpers.tpl # Named template definitions
│ └── NOTES.txt # Post-install instructions
└── tests/ # Test files
└── deployment-test.yaml
The Chart.yaml defines the chart itself:
apiVersion: v2
name: myapplication
description: A Helm chart for My Application
type: application
version: 1.0.0
appVersion: "2.1.0"
keywords:
- webapp
- api
home: https://myapp.example.com
sources:
- https://github.com/myorg/myapp
Template Functions and Sprig
Helm uses Go’s text/template engine extended with Sprig functions. Common categories:
String manipulation:
# values.yaml
releaseName: my-app
environment: production
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-{{ .Values.nameOverride | default .Chart.Name }}
Logical operations:
{{- if .Values.replicaCount > 1 }}
replicas: {{ .Values.replicaCount }}
{{- end }}
{{- if eq .Values.environment "production" }}
strategy:
type: RollingUpdate
{{- end }}
Flow control:
{{- with .Values.image }}
image: "{{ .repository }}:{{ .tag }}"
{{- end }}
{{- range $key, $value := .Values.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
Named Templates and Helpers
The _helpers.tpl file defines reusable templates. These keep your charts DRY and provide consistent naming conventions.
# _helpers.tpl
{{/*
Expand the name of the chart
*/}}
{{- define "mychart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- 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 }}
{{/*
Selector labels
*/}}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Use these in your templates:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: { { include "mychart.name" . } }
labels: { { - include "mychart.labels" . | nindent 4 } }
spec:
selector:
matchLabels: { { - include "mychart.selectorLabels" . | nindent 6 } }
Values Schema Validation
The values.schema.json enforces structure and types on user-provided values. This catches configuration errors before deployment.
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "My Application",
"type": "object",
"properties": {
"image": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"description": "Container image repository"
},
"tag": {
"type": "string"
},
"pullPolicy": {
"type": "string",
"enum": ["IfNotPresent", "Always", "Never"]
}
},
"required": ["repository", "tag"]
},
"replicaCount": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 1
},
"service": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["ClusterIP", "NodePort", "LoadBalancer"]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
},
"required": ["type", "port"]
}
},
"required": ["image"]
}
When users provide invalid values, Helm reports the error clearly:
$ helm install myapp ./mychart -f values.yaml
Error: values validation error: replicaCount must be less than or equal to 10
Testing with Helm Unittest
The Helm unittest plugin runs tests defined in YAML files under the tests/ directory.
# tests/deployment_test.yaml
suite: Deployment suite
templates:
- deployment.yaml
tests:
- name: should create a deployment
asserts:
- isKind:
of: Deployment
- equal:
path: metadata.name
value: RELEASE-NAME-myapplication
- equal:
path: spec.replicas
value: 1
- name: should have correct labels
asserts:
- equal:
path: metadata.labels.app-kubernetes-io-name
value: myapplication
- name: should use the correct image
set:
image.repository: nginx
image.tag: 1.21
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: nginx:1.21
Run tests with:
helm unittest ./mychart
For more comprehensive validation, consider ct (Chart Testing) which integrates with CI/CD pipelines and validates against Kubernetes cluster compatibility.
Publishing to Chart Repositories
When your chart is ready, package and publish it:
# Package the chart
helm package ./mychart
# If using ChartMuseum or a similar repo server:
curl -F "chart=@mychart-1.0.0.tgz" http://localhost:8080/api/charts
# For OCI-based registries:
helm chart save ./mychart myregistry.azurecr.io/mychart:1.0.0
helm chart push myregistry.azurecr.io/mychart:1.0.0
Store the index.yaml generated by your repo server. Users then add and install. For managing chart repositories at scale, see Helm Repository Management. For CI/CD integration patterns, see Designing Effective CI/CD Pipelines.
helm repo add myrepo https://myrepo.example.com
helm repo update
helm install myapp myrepo/mychart --version 1.0.0
When to Use / When Not to Use
When to build custom Helm charts
Reach for custom Helm charts when you need to package internal platform components that teams will reuse across projects. Database operators, messaging middleware, monitoring agents, and shared infrastructure services are all good candidates. If you find yourself copying YAML manifests between teams or repositories, that is a chart waiting to happen.
Chart development also makes sense for applications with complex multi-environment configuration. When dev, staging, and production differ in ways that cannot be expressed with simple values overrides, chart templates give you the control to handle that complexity cleanly.
When to skip custom charts
For one-off deployments that will never be reused, a plain Kubernetes manifest with kubectl apply is simpler and has less overhead. If your team is already standardized on a GitOps tool like ArgoCD with its own templating, adding Helm on top may be redundant.
Do not build a chart just because Helm is the trendy tool. A chart that wraps a single Deployment with no parameterization adds indirection without value.
Chart Development Lifecycle Flow
flowchart TD
A[Write Chart.yaml<br/>Define metadata] --> B[Create templates<br/>deployment.yaml, service.yaml]
B --> C[Add _helpers.tpl<br/>Named templates]
C --> D[Define values.yaml<br/>Default configuration]
D --> E[Add values.schema.json<br/>Validation]
E --> F[Write tests<br/>helm unittest]
F --> G{Tests pass?}
G -->|No| H[Fix templates<br/>or tests]
H --> F
G -->|Yes| I[Package & publish<br/>helm package]
I --> J[Lint & security scan<br/>helm lint, trivy]
J --> K[Add to chart repo<br/>or OCI registry]
Production Failure Scenarios
Template Rendering Failures
Go template errors in charts produce unhelpful messages at deployment time rather than development time. A missing closing bracket or incorrect Sprig function silently renders empty values.
# Always dry-run before installing
helm upgrade --install myapp ./mychart --dry-run --debug
# Catch schema errors early
helm lint ./mychart --strict
Test Coverage Gaps
Tests that only verify happy paths miss regressions in edge cases. If your chart has conditional resources (ingress, PVCs, init containers), write tests for both enabled and disabled states.
# Test that ingress is NOT rendered when disabled
templates:
- ingress.yaml
tests:
- name: should not render ingress when disabled
set:
ingress.enabled: false
asserts:
- isNull:
path: spec
Version Drift in Dependencies
Charts that depend on external charts from Bitnami or other public repositories can break when those dependencies release new versions. A chart that worked last month may fail this month because a sub-chart changed its value structure.
Always run helm dependency update in CI and commit the resulting Chart.lock. Pin exact versions, not version ranges.
Release Name Collisions
Helm releases are identified by name within a namespace. Two helm install commands with the same name overwrite each other. The --generate-name flag or namespaced release naming conventions prevent accidental overwrites.
Resource Scope Mistakes
A chart that creates cluster-scoped resources (like CustomResourceDefinitions or cluster roles) cannot be installed into a single namespace. If your chart needs both namespace-scoped and cluster-scoped resources, document this requirement explicitly.
Observability Hooks
Track chart rendering and deployment health with these observability practices.
Template Debugging
# Render locally without installing
helm template myapp ./mychart --debug
# Inspect the full rendered manifest
helm template myapp ./mychart | kubectl apply --dry-run=server
# Watch what Helm does step by step
helm upgrade --install myapp ./mychart --dry-run --debug --replace
Release Introspection
# See all values passed to a release
helm get values myapp --all
# View the rendered templates for a live release
helm get manifest myapp
# Check release history and status
helm history myapp
helm status myapp
CI/CD Validation Pipeline
# Example CI pipeline for chart development
- name: Lint and test
run: |
helm lint ./mychart --strict
helm unittest ./mychart
ct lint --charts ./mychart
- name: Security scan
run: |
trivy chart ./mychart
helm cm-lint ./mychart
- name: Render validation
run: |
helm template myapp ./mychart --debug
Common Pitfalls / Anti-Patterns
Overly generic values names
Naming values value1, value2 instead of replicaCount, imageTag makes charts impossible to use without reading the source.
Hardcoding release name
Using .Release.Name directly instead of through a helper means the chart only works when installed with a specific release name pattern.
Missing default values
Omitting defaults from values.yaml forces users to provide all values, even for optional settings. Always provide sensible defaults.
Not using JSON schema validation
Without values.schema.json, invalid values fail at template render time with confusing Go template errors. Schema validation catches mistakes immediately with clear messages.
Forgetting hook idempotency
Hooks that run Jobs or Pods must be designed to run multiple times without creating duplicate resources. Use hook-delete-policy: before-hook-creation and make migration scripts idempotent.
Chart Development Trade-offs
Building a Helm chart involves trade-offs between flexibility, complexity, and maintainability.
| Approach | When to Use | Trade-offs |
|---|---|---|
| Simple values with few conditionals | Single application, few environments | Works until configuration complexity grows |
| Extensive template logic with named templates | Large charts with complex conditional resources | Templates become hard to read and debug |
| JSON schema validation | Charts used by multiple teams | Schema changes require chart version bumps |
| Library charts for shared templates | Platform teams standardizing patterns | Version synchronization across teams adds overhead |
| Helm unittest for test coverage | Charts with conditional resources or complex logic | Tests slow down chart development; need CI integration |
| ChartMuseum for internal repos | Single team or organization | ChartMuseum requires maintenance; no built-in image registry |
| OCI artifacts for charts | Teams already using OCI registries | Requires Helm 3.8+; less mature ecosystem support |
The practical rule: start simple. Add template complexity only when the duplication becomes unmanageable. Add schema validation when the chart will be used by others. Add testing when the chart has multiple conditional resources that could break in unexpected combinations.
Interview Questions
Expected answer points:
- Chart.yaml contains chart metadata (name, version, appVersion, dependencies)
- values.yaml provides default configuration values that users can override
- templates/ directory holds Kubernetes manifest templates with Go templating
- _helpers.tpl defines named templates for DRY code and consistent naming conventions
- values.schema.json (optional) enforces values validation via JSON schema
- tests/ directory contains unittest YAML files for validating template output
- NOTES.txt provides post-install instructions displayed after successful installation
Expected answer points:
- Sprig adds string functions (upper, lower, trunc, replace, quote), date functions (now, date), and math functions (add, mul)
- Logical operations: and, or, not, ternary operator (queal)
- Flow control: with, range, if/else if/else for conditional rendering
- Type conversions: toYaml, toJson, toDecimal for data transformation
- Default function: .Values.foo | default "fallback" provides fallback when value is null
Expected answer points:
- Named templates (partials) live in templates/_helpers.tpl and define reusable template blocks
- Define once, include everywhere using {{ include "mychart.name" . | nindent 4 }}
- Common uses: chart name (truncated to 63 chars), fullname (release-chart), labels, selectorLabels, common annotations
- Promote consistency across all Kubernetes resources in the chart
- Keep charts DRY — without helpers, you repeat label selectors across deployment.yaml, service.yaml, ingress.yaml
Expected answer points:
- values.schema.json enforces type constraints (string, integer, boolean, object, array), required fields, and allowed values (enum)
- Minimum and maximum constraints catch out-of-range values before template rendering
- Helm validates with --strict flag during helm lint and helm template
- Provides clear error messages like "replicaCount must be less than or equal to 10" instead of cryptic Go template errors
- Without schema validation, a typo in a template produces an empty value that is hard to debug
Expected answer points:
- Test both enabled AND disabled states for conditional resources (ingress, PVCs, init containers)
- Use isNull assertion to verify resources are NOT rendered when disabled
- Use set: block to override values for specific test scenarios
- Verify actual rendered values (path: spec.replicas, value: 3) not just that template renders
- Test that ingress is NOT rendered when disabled: set ingress.enabled: false, assert isNull path: spec
Expected answer points:
- Dependencies are defined in Chart.yaml under dependencies[] with name, version range, and repository URL
- helm dependency update downloads dependencies to the charts/ directory
- Chart.lock is auto-generated and locks exact versions from dependency resolution
- Commit Chart.lock to Git for reproducible installs across machines and CI pipelines
- helm dependency build installs from Chart.lock without re-resolving dependencies
- Pin exact versions, not version ranges, to prevent supply chain breakages when sub-charts release new versions
Expected answer points:
- Hooks run Jobs or Pods at specific lifecycle points: pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete, test
- Hook weight controls execution order — negative weights run first, important for migration jobs that must complete before application starts
- Design hooks to be idempotent — use hook-delete-policy: hook-succeeded,before-hook-creation to allow re-runs
- Database migrations are the classic use case — they must tolerate being re-run after failed upgrades
- hook-delete-policy options: hook-succeeded (delete after success), before-hook-creation (delete before next run), hook-failed
- Non-idempotent hooks cause duplicate resource creation on retry
Expected answer points:
- OCI support in Helm 3.8+ allows distributing charts via container registries (Azure Container Registry, AWS ECR, etc.)
- helm registry login for authentication, helm push for publishing charts as OCI artifacts
- Benefits: unified authentication with container images, no separate chart repository server needed, charts travel with images in air-gapped setups
- OCI registries handle deduplication and layering efficiently
- Limitation: OCI registries do not serve index.yaml, so helm search does not work with OCI-based charts
- Use helm pull oci:// with exact version flags rather than helm search repo
Expected answer points:
- Use helm template myapp ./mychart --debug to render locally without cluster access
- Use printf debugging in templates: {{ .Values | toJson }} to inspect values at render time
- Use helm lint --strict to catch schema violations and syntax errors
- Use helm upgrade --install --dry-run --debug to validate against a live cluster without making changes
- Use helm get manifest to inspect what was actually deployed to a cluster
- Check for whitespace issues — the minus sign in {{- trims preceding whitespace, including newlines
Expected answer points:
- Not using JSON schema validation — invalid values fail at template render time with confusing Go template errors
- Hardcoding .Release.Name directly instead of through a helper — chart only works with specific release name patterns
- Missing default values in values.yaml — forces users to provide all values, even for optional settings
- Forgetting hook idempotency — migration jobs that are not idempotent create duplicates on retry
- Version drift in dependencies — using version ranges instead of pinned versions causes supply chain breakages
- Overusing toYaml — unbounded toYaml makes templates hard to read and debug; explicitly define expected fields instead
Expected answer points:
- Never commit secrets to values.yaml or Chart.yaml — use External Secrets Operator to sync from Vault, AWS Secrets Manager, or GCP Secret Manager
- Use --set-file to load certificate or key file contents at deploy time instead of embedding them
- HashiCorp Vault CSI provider injects secrets as mounted files without pod-level secret synchronization
- For testing, use test values that are clearly marked as non-production
- Reference secrets from external secret stores in templates rather than storing them in the chart
Expected answer points:
- Helm 3 introduced three-way merge to prevent accidental rollback on configuration drift
- Three-way merge considers: last release manifest, current cluster state, new values — and only applies changes from new values
- This prevents overwriting changes made directly in the cluster that are not in the previous release
- Four-way diff (upgrade) compares: last release, current cluster, new template, new values
- Result: cluster changes outside Helm are preserved, preventing accidental overwrites in GitOps workflows
Expected answer points:
- Application charts set type: application and produce actual Kubernetes resources when installed
- Library charts set type: library and define reusable template partials (_deployment.yaml, _service.yaml, _configmap.yaml)
- Other charts depend on library charts and import their templates via import-values
- Library charts are useful for standardizing organization-wide patterns across teams — e.g., a common monitoring deployment template
- Library charts cannot install resources directly, only provide templates; they are never deployed on their own
Expected answer points:
- Each microservice gets its own chart in a charts/ directory at repo root
- Use Helmfile at the repo root to declare all chart releases and their dependencies
- Extract shared templates into library charts that all microservices import
- Use values-{env}.yaml files at the repo level, not inside individual charts
- Group related services (e.g., backend-api, frontend-web) under a single release if they deploy together
- Use hook-weight for migration jobs that must run before application pods start
Expected answer points:
- Helmfile is a declarative tool that sits above Helm, managing multiple charts in one file
- Prefer Helmfile when managing 3+ charts or multiple environments (dev, staging, production)
- Environment blocks with overrides keep environment-specific config auditable
- helmfile diff shows exactly what would change before applying, useful in CI/CD
- Plain Helm requires manual per-chart commands and --set flags for each environment
- Not needed for simple single-chart deployments where Helm alone suffices
Expected answer points:
- Store chart versions in Git — ArgoCD syncs applications when the version tag changes
- Use ArgoCD sync waves or application sets with rollback capability if issues occur
- Helm Deprecation Warning: ArgoCD tracks release revisions — rolling back uses helm rollback within the cluster
- For breaking changes, document upgrade path in Chart.yaml annotations or a separate UPGRADE.md
- Test chart upgrades in staging before production — use helm diff upgrade to preview changes
Expected answer points:
- Helm upgrade is additive — new resources are created, changed resources are updated, removed resources are not deleted unless --uninstall is used
- For resources that need to be replaced (not updated), use helm.sh/resource-policy annotation: keep to prevent deletion
- Hooks run during upgrade: pre-upgrade hook (before update), post-upgrade hook (after update)
- If a Job hook fails during upgrade, the release is marked as failed and the upgrade does not complete
- Use --atomic flag to automatically rollback if upgrade fails
Expected answer points:
- ct lint --charts ./mychart validates chart syntax and structure but not against specific K8s versions
- Use kubeval or cfssl to validate rendered manifests against multiple K8s API versions
- Set kubeVersion in Chart.yaml to specify compatible K8s versions and warn users of mismatches
- Use kind or minikube in CI to spin up actual K8s clusters at different versions for integration testing
- Helm unittest runs chart templates locally without a cluster — good for unit testing but not for integration
Expected answer points:
- JSON schema validates values before template rendering — catches wrong types, out-of-range values, missing required fields early
- Go template validation catches syntax errors and missing values during template rendering
- JSON schema gives clearer error messages; Go template errors are cryptic (e.g., "function not defined" for typos)
- JSON schema is declarative and easier to review; Go template logic is imperative and harder to validate
- Best practice: use both — JSON schema for values validation, Go template logic for conditional rendering
Expected answer points:
- Stateful applications require careful hook design for database migrations, data backup, and restore procedures
- Pre-upgrade hook runs migrations before new pods start — use hook-weight: -1 to run early in upgrade sequence
- Make migrations idempotent — check if migration has already been applied before running to avoid duplicates
- Use hook-delete-policy: before-hook-creation,hook-succeeded to clean up after successful runs
- Post-rollback hooks should restore data from backup if the upgrade causes data corruption
- Test rollback scenarios in staging — simulate failure and verify data integrity after rollback
Further Reading
- Helm Chart Template Guide - Official guide for Go templating and Sprig functions
- Helm Unittest plugin - Write unit tests for chart templates
- ct (Chart Testing) - Lint and test charts in CI pipelines
- Artifact Hub - Browse community Helm charts and find best practices
- Helmfile documentation - Declarative spec for managing multiple Helm releases
- GitHub Action for Chart Testing - CI integration for chart validation
Conclusion
Key Takeaways
- Directory structure, named templates, and values schema validation form the foundation of maintainable charts
- Helm unittest and ct provide test coverage that catches regressions before users encounter them
- Always dry-run and lint in CI before publishing
- Chart dependencies need locked versions to prevent supply chain breakages
Development Workflow Checklist
# 1. Create chart
helm create ./mychart
# 2. Add templates, values, and helpers
# 3. Add JSON schema validation
# Edit values.schema.json
# 4. Write tests
mkdir tests && vim tests/deployment_test.yaml
# 5. Run tests
helm unittest ./mychart
# 6. Lint
helm lint ./mychart --strict
# 7. Package
helm package ./mychart
# 8. Install from local chart
helm upgrade --install myapp ./mychart-1.0.0.tgz --dry-run
For more on Helm basics, see our Helm Charts guide. If you are interested in GitOps-style chart management, our GitOps article covers declarative deployment patterns.
Category
Related Posts
Helm Versioning and Rollback: Managing Application Releases
Master Helm release management—revision history, automated rollbacks, rollback strategies, and handling failed releases gracefully.
Helm Charts: Templating, Values, and Package Management
Helm Charts guide covering templates, values management, chart repositories, and production deployment workflows.
Container Security: Image Scanning and Vulnerability Management
Implement comprehensive container security: from scanning images for vulnerabilities to runtime security monitoring and secrets protection.