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: 35 min read author: GeekWorkBench updated: June 17, 2026
Quick Summary

Helm charts package Kubernetes manifests with Go templating so one chart serves dev, staging, and production without duplication. Chart.yaml holds metadata, values.yaml sets defaults, templates/ renders manifests, and _helpers.tpl defines reusable partials. Sprig functions handle conditionals and transforms; values.schema.json validates inputs early with readable errors instead of template crashes. The helm unittest plugin runs YAML test suites that catch regressions across value combinations before users hit them. Library charts share template code across an org's charts; application charts describe actual workloads. Start plain; add complexity only when copy-paste gets painful.

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.

The practical trigger is repetition across repos. When you notice the same deployment pattern appearing in three or more places with only minor differences, a chart eliminates the overhead of keeping copies in sync. At that scale, platform teams typically extract the pattern into a library chart that other teams import — this standardizes how services are deployed without requiring each team to own the chart directly.

Here is a scenario that plays out repeatedly: your team maintains a Python FastAPI service template that other teams copy and customize. After the fifth team adopts it, someone adds a new environment variable to the original. None of the five copies receive it. A Helm chart with a values schema pushes that change to all installs at upgrade time, without any manual propagation.

Build a chart when:

  • The same Kubernetes resources show up across 3+ repos with minor variations
  • Multiple teams need different configurations for the same underlying application
  • You want centralized control over security labels, resource limits, or annotations across all services
  • Onboarding a new service means more than copying an existing manifest

Skip Helm when:

  • You are deploying a single static manifest that will never need to vary between environments
  • Your team already uses ArgoCD or Flux with its own templating layer — Helm would be redundant
  • The application has no parameters to vary — one config fits all environments

The rule I use: if you are copying YAML between repos more than twice, build the chart.

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.

The clearest signal to reach for kubectl apply instead of a chart is a manifest that never varies. If the YAML you are deploying today is identical to the YAML you will deploy six months from now, with no values to override, no environments to manage, and no team other than your own consuming it, Helm buys you nothing. You write the manifest, you apply it, you are done. A chart in front of that adds a layer of indirection that you will navigate every time you make a change, without any corresponding benefit.

The second scenario where Helm becomes redundant is when your team has already standardized on a GitOps tool with its own templating layer. ArgoCD and Flux both support kustomize overlays and value overrides that handle multi-environment configuration without Helm. If your ArgoCD Application already applies kustomize patches for dev, staging, and production, wrapping those overlays in a Helm chart means maintaining two templating systems for the same deployment. The overlap creates a question no one on the team can answer quickly: which system is authoritative? When something breaks, you check ArgoCD first or Helm first? That confusion has a real cost during incidents. If your team is already fluent in one tool and that tool covers your needs, pick one and stick with it.

The anti-pattern worth naming explicitly: building a Helm chart for a single Kubernetes Deployment that has no values.yaml overrides. The chart has a Chart.yaml, a templates/ directory, and a values.yaml that contains only defaults from helm create. The Deployment in templates/deployment.yaml uses .Values.image but never overrides it. Every install is helm install myapp ./mychart. There is no parameterization, no conditional resource, no reuse across environments. The chart exists because someone heard Helm was the right tool for Kubernetes packaging. It is not wrong to package it as a chart, but it is unnecessary complexity that will slow down every future change to that manifest.

Chart Development Lifecycle Flow

The diagram shows how production chart development actually goes: from scaffolding to publishing, but with arrows pointing backward at every stage. Charts routinely send you back to fix templates or rewrite tests when new edge cases surface. That feedback cycle between writing tests and fixing templates is where the real work happens.

The lint and security scan step is the last gate before the chart reaches consumers. Catching template bugs here instead of in production is the point of the whole workflow.

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.

The problem with version ranges in Chart.yaml dependencies is that they resolve to the latest matching version at resolution time. Bitnami and other chart publishers release breaking changes without incrementing the major version, which violates semantic versioning expectations. A dependency declared as version: "15.x" resolves to 15.5.0 today and silently becomes 15.12.0 next month. If that sub-chart renamed a value you depend on, your templates render empty or fail. The chart you tested and shipped no longer matches what gets installed. This is the failure mode that Chart.lock exists to prevent.

The Chart.lock file records the exact resolved versions from helm dependency update. When you run helm dependency update ./mychart, Helm resolves all version ranges in Chart.yaml, downloads the resolved charts, and writes the exact versions to Chart.lock. Committing this file to Git means every machine and every CI run starts from the same foundation. helm dependency build (which reads Chart.lock instead of resolving fresh) produces identical output regardless of when or where it runs. Without Chart.lock, you are one helm dependency update away from a supply chain breakage that no one on your team intentionally introduced.

A minimal CI workflow that prevents version drift:

# In CI pipeline after Helm lint and unittest pass
- name: Update chart dependencies
  run: |
    helm dependency update ./mychart
    git diff --exit-code Chart.lock && echo "Chart.lock unchanged" || {
      git add Chart.lock
      git commit -m "chore: lock chart dependency versions"
      git push origin ${{ github.head_ref }}
    }

The git diff --exit-code Chart.lock step detects whether helm dependency update changed anything. If it did, the pipeline commits the updated lock file and pushes. This creates an auditable trail of when dependency versions changed, which is useful during incident review. Without this step, dependency updates happen silently or not at all, and the lock file drifts from what the chart was tested against.

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.

When two teams run helm install myapp in the same namespace, the second call silently replaces the first. The deployed application disappears and gets replaced without warning. In CI/CD pipelines this happens when multiple pipelines target the same namespace with hardcoded release names, or when a developer runs helm install locally against a cluster that already has the release.

Use a naming pattern that separates environments, like {{ .Values.environment }}-{{ .Values.nameOverride | default .Chart.Name }}, so dev and production releases never share a name. In shared namespaces, coordinate via a naming registry or reach for --generate-name on ephemeral deployments.

# Check if a release already exists before installing
helm status myapp && echo "Release exists" || helm install myapp ./mychart

# Use generate-name for temporary installations
helm install myapp ./mychart --generate-name

# List all releases to audit what's deployed
helm list --all -n mynamespace

If you accidentally overwrite a release, helm rollback myapp restores the previous revision as long as the history has not been cleared. The helm upgrade --install --force sequence replaces the resources but preserves the release history, which matters if someone needs to audit what happened.

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

When a template renders wrong, reach for helm template first. It renders locally without touching any cluster, so you get immediate feedback after a change. The --debug flag dumps the full rendered manifests plus the template engine debug output — useful for seeing which values are actually being passed and which conditionals flipped.

For issues that only show up against a live API, pipe the output to kubectl apply --dry-run=server. This validates against the real Kubernetes API in your target cluster, catching version mismatches and resource kind errors that helm template misses. Server-side dry-run shines when you need to confirm compatibility with the specific Kubernetes version running staging or prod.

The --replace flag on helm upgrade --install --dry-run --debug --replace reinstalls the release if it already exists, letting you test how the chart behaves during an upgrade without actually modifying the cluster.

# 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

Debugging a live release means pulling information back out of Helm instead of just pushing templates in. Three commands handle the most common cases: what values are active, what was actually created, and what the release history looks like.

helm get values dumps the user-supplied values for a release. Add --all and it includes computed defaults too, not just what was explicitly set at install time. This shows what the chart is working with when it renders templates — useful when a release behaves unexpectedly because of a default you forgot was in effect.

helm get manifest shows the full rendered templates that Helm applied to the cluster, including hooks and injected labels. This is what Helm thinks is currently deployed, which may differ from reality if something modified resources outside of Helm.

helm history lists every revision with timestamps and statuses. When something goes wrong, the history shows which revision introduced the change and whether rolling back would help.

# 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

A CI pipeline for Helm charts runs three validation stages before publishing or deployment. Skip any of them and you ship lower-quality charts.

The lint and test stage runs helm lint --strict to catch schema errors, helm unittest for your YAML test suite, and ct lint for Helm community conventions. They overlap somewhat but each catches different things: helm lint is your first gate, helm unittest validates your chart logic, and ct lint ensures the chart meets repository standards.

The security scan stage runs trivy chart for vulnerabilities in dependencies and container images, and helm cm-lint for ConfigMap and Secret misconfigurations like plain-text secrets or env-mounted ConfigMaps. These belong in CI because they should run on every pull request, not just before releases.

The render validation stage runs helm template --debug as a final sanity check in the target environment. Less thorough than deploying to a test cluster but faster without requiring cluster access.

# 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.

Value names are the API surface of your chart. When a user runs helm install myapp ./mychart --set replicaCount=3, they expect the name to tell them what it does. Generic names like value1 or configMapData force users to open values.yaml and read the defaults before they can make any sense of the chart.

Descriptive names follow a pattern: noun or noun.Noun. For container images, image.repository, image.tag, image.pullPolicy tells you exactly what each value controls. For scaling, replicaCount is self-explanatory; count or replicas is ambiguous. Group related values under a prefix: ingress.enabled, ingress.hostname, ingress.tls keeps ingress configuration organized.

The anti-pattern sneaks in when charts evolve. The defaults from helm create include service.port: 80 which is descriptive enough, but as features get added, values like env, config, or extraLabels accumulate. When env holds 15 environment variables with no documentation, users have to guess which keys are available. Break generic containers into specific values: env.API_URL, env.LOG_LEVEL, env.WORKER_CONCURRENCY.

A useful test: hand someone your values.yaml with no comments. Can they configure the chart correctly? If yes, the names are doing their job.

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.

.Release.Name carries the release name exactly as you provide it on the command line. This is fine when the name follows Kubernetes rules, but breaks when someone installs with My App v1 and gets a cryptic error about invalid DNS characters.

The helper from _helpers.tpl handles this automatically: it truncates to 63 characters, strips invalid characters, and removes trailing hyphens. A release named my-app-v1 works, and so does My App v1 after sanitization.

The helper also enables consistent prefixing. If your team uses environment prefixes like prod- or dev-, putting that logic in the helper means every resource gets it without requiring --set nameOverride=prod-myapp every time.

Use the helper for resource names, use fullname for anything that needs sanitization. The distinction matters when the same chart runs in multiple namespaces: myapp-staging and myapp-prod should both produce valid Kubernetes resource names without you having to think about it.

# Wrong — raw release name, fails with spaces or special chars
name: {{ .Release.Name }}-deployment

# Right — goes through the helper for sanitization
name: {{ include "mychart.fullname" . }}-deployment

# In _helpers.tpl
{{- define "mychart.fullname" -}}
{{- .Release.Name | trunc 54 | trimSuffix "-" }}-{{ .Chart.Name }}
{{- end }}

Missing default values

Omitting defaults from values.yaml forces users to provide all values, even for optional settings. Always provide sensible defaults.

A chart without defaults forces users to read the source before they can deploy anything. Every value that has a reasonable default should have one. The goal: a user running helm install myapp ./mychart gets a working deployment without additional configuration.

Defaults do more than enable deployment. They document what values exist, establish the safe path for new users, and shrink the surface area you need to support. A resources block with no defaults means every user has to look up the exact structure before setting CPU and memory limits. A replicaCount with no default means the chart must handle nil or fail at render time.

Not all values need defaults. An optional ingress.hostname is a good example: leave it empty and check {{- if .Values.ingress.hostname }} in the template. That is valid behavior, but add a comment in values.yaml so users know the value is optional and what happens when it is omitted.

For nested objects, default at the right granularity. Set image.repository: nginx and image.tag: latest separately rather than image: "nginx:latest" as a string. This way users can override just the tag without retyping the full image path. The same logic applies to any composite value: default at the most granular level that still makes sense, so users only override what they need to change.

For optional values with no sensible default, add a comment explaining the behavior:

# Optional. If not set, ingress will not be created.
# ingress:
#   enabled: true
#   hostname: myapp.example.com
ingress: {}

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.

Go template errors are unhelpful at the best of times. A misspelled function name gives you “function not defined” with no clue about which value triggered it or what the correct spelling is. An out-of-range integer in a conditional silently renders nothing. These surface at deployment time, after you have already committed to the action.

JSON schema validation runs before any template rendering. Helm reads values.schema.json, validates all user-provided values, and exits with a clear message before touching the cluster. If replicaCount gets a string instead of an integer, or image.tag is missing from a required field, Helm tells you exactly what went wrong.

The schema also documents expected types and constraints in a machine-readable format. helm lint --strict runs schema validation in CI, catching issues before the chart is published. Anyone installing from helm repo add gets a chart that has already been validated against its own schema.

Writing a schema forces you to think about which values are required versus optional. Charts without schemas tend to accumulate required values over time as maintainers discover new failure modes. Adding required fields to a published chart is a breaking change. Better to start with a schema that reflects the actual constraints and update it deliberately as the chart evolves.

# Validate values against schema without rendering templates
helm lint --strict ./mychart

# The schema catches type errors early
# Error: values validation error: replicaCount must be integer

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.

Helm hooks run at specific points in the release lifecycle: pre-install, post-install, pre-upgrade, post-upgrade, pre-delete, post-delete. When a hook runs a Job and the upgrade fails partway through, Helm retries the hook when you re-attempt the upgrade. If the Job is not idempotent, the second run creates duplicates or corrupts state.

Database migration jobs are the most common case. A migration that inserts a row into a versions table on every run hits duplicate key errors on retry. A migration that uses INSERT OR IGNORE or checks for existing records before inserting is idempotent — running it multiple times produces the same result as running it once.

The hook-delete-policy annotation controls when Helm deletes a hook resource after it completes. before-hook-creation deletes the previous hook job before running the next one, handling retries cleanly. hook-succeeded deletes after successful completion, useful for jobs you do not want to retain. Combine them:

annotations:
  helm.sh/hook: pre-upgrade
  helm.sh/hook-weight: "-1"
  helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded

Hook weight controls execution order. Negative weights run first, which matters for migration hooks that must complete before new pods start. Use -1 for migrations. If the migration hook fails, the upgrade does not proceed — which is what you want. You do not want new pods starting before their data is migrated.

Test hook behavior by simulating failures. Install the chart, trigger an upgrade that fails during the hook phase, then re-run the upgrade. Verify the second run succeeds and no duplicate resources exist. Without this test, you will not know whether your hooks are safe to retry until production forces the issue.

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