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.

published: reading time: 21 min read author: GeekWorkBench

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.

ApproachWhen to UseTrade-offs
Simple values with few conditionalsSingle application, few environmentsWorks until configuration complexity grows
Extensive template logic with named templatesLarge charts with complex conditional resourcesTemplates become hard to read and debug
JSON schema validationCharts used by multiple teamsSchema changes require chart version bumps
Library charts for shared templatesPlatform teams standardizing patternsVersion synchronization across teams adds overhead
Helm unittest for test coverageCharts with conditional resources or complex logicTests slow down chart development; need CI integration
ChartMuseum for internal reposSingle team or organizationChartMuseum requires maintenance; no built-in image registry
OCI artifacts for chartsTeams already using OCI registriesRequires 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

1. How do you structure a Helm chart directory and what files are essential?

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
2. How does the Sprig function library extend Go templates in Helm?

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
3. What are named templates and why are they important in Helm chart development?

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
4. How does values.schema.json prevent misconfiguration in Helm charts?

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
5. How do you write effective Helm unittest tests for charts with conditional resources?

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
6. How does Helm handle chart dependencies and what is the Chart.lock file?

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
7. What are Helm hooks and how do you design them for production use?

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
8. How do you publish Helm charts to OCI registries and what are the benefits?

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
9. How do you debug Helm templates when they produce unexpected output?

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
10. What are the most common mistakes when developing Helm charts for production?

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
11. How do you manage secrets in Helm charts without hardcoding them?

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
12. How does the Helm three-way merge work in Helm 3 and why is it safer than Helm 2?

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
13. When would you choose library charts over application charts in Helm?

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
14. How do you structure Helm charts for monorepos with multiple microservices?

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
15. What is the difference between Helmfile and plain Helm for managing multi-environment deployments?

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
16. How do you handle chart version upgrades in a production environment with ArgoCD?

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
17. How does Helm handle resource lifecycle when upgrading a release with existing resources?

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
18. How do you test Helm charts against multiple Kubernetes versions in CI?

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
19. What are the trade-offs between JSON schema validation and Go template validation for Helm charts?

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
20. How do you design Helm charts for stateful applications that require migration hooks?

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

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 #kubernetes #devops

Helm Charts: Templating, Values, and Package Management

Helm Charts guide covering templates, values management, chart repositories, and production deployment workflows.

#kubernetes #helm #devops

Container Security: Image Scanning and Vulnerability Management

Implement comprehensive container security: from scanning images for vulnerabilities to runtime security monitoring and secrets protection.

#container-security #docker #kubernetes