Helm Chart Best Practices

Production engineering guide for helm chart best practices covering patterns, implementation strategies, and operational best practices.

Orientation This page collects concrete, repeatable practices for writing, maintaining, and troubleshooting Helm charts. It matters whenever you package Kubernetes applications as versioned, shareable units—whether you’re onboarding a new team, automating deployments across clusters, or auditing drift between environments. The following sections are organized by what you’re trying to accomplish, not by a file‑format taxonomy.

Rendering and Debugging a Chart

Preview rendered templates locally

helm template renders a chart’s templates without contacting the Kubernetes API. It reads the chart directory, applies the default values.yaml, and prints the resulting manifests. This is the fastest way to verify hooks, resource names, and conditional logic before pushing to a cluster.

helm template myrelease ./mychart

If your environment has no kubeconfig, helm template still succeeds because it never talks to the API server. When a config exists but the context is unset, you’ll see Error: no configuration has been provided; exporting KUBECONFIG or running kubectl config current-context resolves it.

Override values inline:

helm template myrelease ./mychart --set replicaCount=3

Use --set-string when the value isn’t valid JSON:

helm template myrelease ./mychart --set-string image.tag=v1.2.3

Confused flags: --set expects a JSON‑compatible or comma‑separated value. --set-string always treats the right‑hand side as a literal string. Running --set image.tag=v1.2.3 works because v1.2.3 parses as a string, but --set image.tag="v1.2.3" embeds the quotes literally—prefer --set-string if you’re unsure. Conversely, --set-string will store a string even if the template expects an integer, causing a type mismatch on the next render.

Linting for common problems

helm lint scans a chart directory for best‑practice violations. Typical checks include:

helm lint ./mychart

Sample output for a missing apiVersion:

Error: found in templates but not in charts: templates/hooks/job.yaml

And for an undefined value:

Error: template "deployment.yaml" has an undefined value at .Values.image.repository

Running lint in CI before chart publication catches these before they reach a cluster.

Dry‑run install

helm install --dry-run --debug renders templates and validates them against the cluster’s API without creating a release. This catches API‑level mismatches (e.g., a field valid in a template but rejected by the installed Kubernetes version) without persisting anything.

helm install --dry-run --debug myrelease ./mychart

Output ends with Status: failed or Status: pending depending on whether any objects would be created, and the debug log shows the full render + validation trace.

Installing and Upgrading Releases

First install

helm install myrelease ./mychart

This creates a release named myrelease and schedules the objects described by the chart. By default, helm install waits 300 seconds for all resources to become ready; adjust this with --timeout:

helm install myrelease ./mychart --timeout 600s

--timeout sets the wait duration in seconds. If the timeout expires before resources are healthy, the install fails and the partially created release stays in a failed state unless --atomic is also set, in which case the entire release is rolled back.

Upgrading an existing release

helm upgrade myrelease ./mychart

If the release doesn’t exist, helm upgrade behaves like helm install (the --install flag makes this explicit). During an upgrade, Helm performs a strategic merge of the new values against the release’s current values, then renders and applies the diff.

Value merge order: default values in values.yaml → values from the -f flag → values from --set flags → the release’s stored values. Command‑line --set overrides everything, including values from -f. This ordering is a frequent source of “I changed the values file but the release didn’t update” bugs.

helm upgrade myrelease ./mychart -f prod-values.yaml --set replicaCount=5

Here, replicaCount ends up as 5 regardless of what prod-values.yaml specifies.

Controlling rollout strategy

Charts often define a strategy block in the deployment template. Helm passes this through; the default is RollingUpdate. For a Recreate strategy, ensure your template specifies it:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
spec:
  strategy:
    type: Recreate
  replicas: {{ .Values.replicaCount }}
  ...

Rolling back

helm rollback myrelease 3

This reverts the release to revision 3, re‑rendering templates from that revision’s stored values. Rollback is safe only if the target revision was successful; if it failed, Helm returns Error: release "myrelease" has no revision 3 or Error: cannot rollback to a failed revision.

List revisions:

helm history myrelease

Sample output:

REVISION        UPDATED                  STATUS          CHART           DESCRIPTION
1               2024-01-15 10:23:43.123  superseded      mychart-0.1.0   first release
2               2024-01-16 08:11:09.456  failed          mychart-0.1.1   attempted upgrade, hook failed

Managing Chart Dependencies

Most charts rely on external charts—databases, monitoring stacks, ingress controllers. Helm handles these via the requirements.yaml (or the legacy dependencies field in Chart.yaml).

Adding and updating dependencies

helm dependency update

This reads Chart.yaml (or requirements.yaml), resolves the specified versions, downloads the referenced charts into charts/, and generates or updates Chart.lock. The lock file ensures reproducible builds: anyone running helm dependency build later will install exactly the versions recorded in the lock, not re‑resolve from the remote index.

If you only need to fetch a chart without updating the version, use helm dependency build:

helm dependency build

This reads the existing Chart.lock and populates charts/ without contacting remote repos. Running helm dependency build on a repo without a lock file produces Error: no lock file found, run dependency update first.

Version pinning

In Chart.yaml, each dependency entry specifies a version range:

dependencies:
  - name: prometheus-kube-state-metrics
    repository: oci://ghcr.io/prometheus/kube-state-metrics
    version: "5.x"

The version field supports semver ranges (~, >=, <), but pinning to an exact version or a narrow range prevents silent upgrades when the repository maintainer publishes a new major version. After changing versions, always run helm dependency update to regenerate the lock.

OCI‑based dependencies

Modern Helm charts can pull dependencies from OCI registries:

helm dependency add oci://ghcr.io/example/chart --version 1.2.0

This writes the dependency entry to Chart.yaml and fetches the blob into charts/. OCI URLs replace the older repository: format that pointed to Helm v2‑style chart repositories. The standalone helm pull oci://... command also works for pulling a single chart directly:

helm pull oci://ghcr.io/example/chart --version 1.2.0

This saves the chart archive to the current directory. OCI pull requires appropriate registry credentials; if your dockerconfigjson doesn’t include the registry, you’ll get Error: unauthorized: authentication required.

Testing and Linting

Built‑in lint rules

helm lint runs a set of checks defined in Helm’s source. Key rules include:

Running helm lint before merging chart changes prevents the most common publishing rejections.

Unit tests inside a chart

Helm supports tests that run after a release is installed. Define them in tests/tests.yaml:

apiVersion: v1
kind: Test
metadata:
  name: mychart-test
spec:
  template:
    spec:
      containers:
        - name: curl
          image: curlimages/curl:8.0.0
          command: ["curl", "-fsS", "{{ .Values.healthCheckEndpoint }}"]
      restartPolicy: Never

Apply tests during install:

helm install myrelease ./mychart --set test.enabled=true

Or run them manually against an existing release:

helm test myrelease

helm test executes each test container, captures exit code, and reports pass/fail. If a test times out (default 60 seconds), it fails with Error: test "mychart-test" timed out after 60s. Adjust the timeout with --timeout on helm test.

Common test patterns

Keep test images small and stateless; avoid tests that depend on cluster‑scoped resources unless your chart installs them.

Sharp Edges: What Silently Fails

Hook lifecycle pitfalls

A common surprise is the hook-delete-policy annotation. If you omit it, the hook pod is not garbage‑collected when helm uninstall runs, leaving a defunct pod in the namespace:

apiVersion: v1
kind: Job
metadata:
  name: {{ include "mychart.fullname" . }}-backup
  annotations:
    "helm.sh/hook": pre-upgrade
    "helm.sh/hook-delete-policy": hook-succeeded,hook-failed

Without that annotation, helm uninstall myrelease completes but you’ll see the backup job still running with kubectl get jobs. This silently accumulates resources across releases.

Another hook gotcha: --atomic combined with any pre/post hook that fails causes the entire release to be rolled back immediately. If you’re debugging a flaky hook, running without --atomic lets the release proceed while the hook logs its output to helm install --debug output.

--set vs --set-string gotchas

As noted earlier, --set interprets its argument as JSON. Passing a value that looks like a number but Helm’s parser treats differently can produce an unexpected type in the rendered template. --set-string always sets a string value, even if the template expects an integer. Mixing them in the same release upgrade can produce a chart that renders once but fails on the next upgrade after a values‑file change re‑merges types.

Inspect what a --set flag produced:

helm get values myrelease

This prints the effective values stored with the release, showing exactly how --set was absorbed during the last install or upgrade.

Rendering without a cluster

helm template does not require a running Kubernetes API server. If you run it on a machine with no kubeconfig, it still outputs rendered manifests. If you do have a config but point it at a cluster where the chart’s API groups aren’t installed (e.g., a minimal kind cluster without metrics-server), helm install --dry-run --debug may succeed in rendering but fail at the API validation step with:

Error: unable to recognize "v1beta1" kind "Pod" in version "v1": no matches for kind "Pod" in group ""

This isn’t a chart error; it’s a cluster‑version mismatch. Always validate renders on a cluster that matches your target version, or use helm template locally and apply the output manually.

Default template helpers and nil values

Helm’s .Values helpers return nil when a key is absent from values.yaml and has no default. In templates, {{ .Values.replicaCount }} prints nothing (empty string), while {{ .Values.replicaCount | default 1 }} substitutes 1. A frequent bug is writing replicas: {{ .Values.replicaCount }} and wondering why the deployment shows 0 replicas after a fresh install—the default was never set. Always pair potentially‑missing values with | default or declare them in values.yaml.

Value file merge order and silent overwrites

When you run helm upgrade --install with multiple -f flags, Helm merges them in order, then applies --set on top. If two values files define the same key, the last one wins. This is easy to miss in CI pipelines where helm upgrade -f staging.yaml -f prod.yaml might have the files in reverse order of intent. To avoid confusion, prefer a single merged values file for production, or use --set only for override flags that must differ per environment.


helm upgrade myrelease ./mychart -f env.yaml -f overrides.yaml

If overrides.yaml defines replicaCount: 2 and env.yaml defines replicaCount: 3, the release ends up with 2. Reverse the order and you get 3.

helm repo add and index staleness

Adding a repo with helm repo add downloads the repository’s index.yaml into ~/.helm/repos/. That index is not refreshed automatically. Running helm search repo or helm install against a stale repo may return outdated chart versions or, worse, fail to find a version you just pushed. The habit is:

helm repo update

before any search or install that depends on the latest index. Forgetting this is a silent source of “chart not found” errors in scripts.

helm pull oci:// and credential caching

helm pull oci://ghcr.io/example/chart relies on the same credential helpers as docker pull. If you’re using a corporate proxy or an SSH‑key‑based registry, the first pull may prompt for credentials and cache them in ~/.docker/config.json. Subsequent runs in a different shell or CI container without the same volume mounts will fail with Error: unauthorized: authentication required. To avoid this, either mount the docker config volume, use an auth token via HELM_OCI_AUTH, or fall back to HTTP‑based chart repositories where basic auth can be passed on the command line.

Frequently confused flags reference

FlagEffectCommon Misstep
--timeoutSeconds to wait for resource readinessSetting too short causes premature failure; too long blocks CI pipelines
--atomicAbort and rollback on any hook failureUsing it during development makes debugging hook failures harder
--dry-runRender + API validate without persistingOmitting --debug hides the rendered templates when validation fails
--setJSON/comma‑separated inline valuesForgetting that --set overrides -f values, even those from later flags
--set-stringLiteral string valuesUsing it for numeric or boolean templates causes type mismatches

This reference page concludes the core patterns, commands, and failure modes you’ll encounter when working with Helm charts. Keep the sharp‑edges checklist handy; the majority of production incidents stem from the quiet gotchas listed above.

This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.