Skip to content

Helm

Who this page is for: in plain English, Helm packages a set of Kubernetes manifests into a versioned, configurable bundle called a chart, and tracks what you've installed so you can upgrade and roll back. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track.

Helm is the standard package manager for Kubernetes. It wraps a set of manifests into a versioned, configurable artifact called a chart, and tracks deployed instances - called releases - so you can upgrade, diff, and roll back with a single command.

Without Helm, environment-specific configuration lives in duplicated YAML or fragile kustomize overlays. With Helm, you parameterize once and deploy anywhere.

A note on versions: Helm 4.0.0 shipped in November 2025, the first major version bump in six years, and is the actively developed release line as of 2026. Helm 3 is in maintenance: the project announced a bug-fix window followed by a longer security-patch-only window after the Helm 4 GA, and the exact end dates are published on Helm's version support policy and on the Helm blog - check those rather than trusting a date written here. Plenty of production clusters are still on Helm 3, and most of what's below applies to both lines. Helm 4's headline changes: Server-Side Apply is now used by default for new installs, the plugin system was redesigned (Wasm-based plugins, OCI plugin installation), and post-renderers were folded into that plugin system - see the callout in the post-rendering section below, since it changes a command shown on this page. Chart.yaml's apiVersion: v2 format is unchanged and remains the one to use; an experimental v3 chart API exists behind a feature flag but isn't stable yet.

How a release works

flowchart LR
    Chart["Chart\n(templates + defaults)"] --> Render["helm template\n(render with values)"]
    Values["values.yaml\n+ overrides"] --> Render
    Render --> Release["Release in cluster\n(tracked in Secrets)"]
    Release --> Upgrade["helm upgrade\n(diff + apply)"]
    Upgrade --> History["Release history\n(rollback target)"]

Helm stores each release's rendered manifest and metadata as a Secret in the target namespace. That history is what makes helm rollback work - it renders the previous revision's manifest and re-applies it.

Chart anatomy

my-chart/
├── Chart.yaml          # metadata: name, version, appVersion, dependencies
├── values.yaml         # default values for all templates
├── charts/             # vendored sub-charts (from helm dependency build)
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── _helpers.tpl    # named templates (partials) -- not rendered directly
│   └── NOTES.txt       # post-install user-facing instructions
└── .helmignore

Chart.yaml fields that matter

apiVersion: v2
name: my-app
version: 1.4.2          # chart version -- bump this on every chart change
appVersion: "2.0.1"     # application version -- informational only
description: My application chart
type: application        # or "library" for reusable partials
dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: "oci://registry-1.docker.io/bitnamicharts"
    condition: postgresql.enabled

Templating with Sprig

Helm templates use Go's text/template engine augmented with the Sprig function library.

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          {{- with .Values.env }}
          env:
            {{- toYaml . | nindent 12 }}
          {{- end }}

Named templates (_helpers.tpl)

{{/*
Expand the name of the chart.
*/}}
{{- define "my-app.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels applied to every resource.
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

Prefer include over template - include returns a string you can pipe through nindent, quote, and other functions.

Advanced templating: tpl, lookup, and whitespace control

Three patterns separate charts that merely work from charts that hold up under real production use.

tpl - render a value as a template. Sometimes a value itself contains template syntax - a Helm value that a user wants to interpolate with other values at render time (a common case: an annotation or a log-format string that references .Release.Name):

# values.yaml
podAnnotations:
  config-hash: "{{ .Release.Revision }}"
# templates/deployment.yaml
metadata:
  annotations:
    {{- tpl (toYaml .Values.podAnnotations) . | nindent 4 }}

Without tpl, that string is inserted literally - {{ .Release.Revision }} shows up verbatim in the manifest instead of being evaluated. tpl takes a string and the current context (.) and re-runs it through the template engine. It's the mechanism that lets umbrella charts and library charts expose "templatable" values to consumers who don't control the chart source.

lookup - read live cluster state during templating. lookup queries the Kubernetes API for existing objects:

{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace "db-credentials" }}
{{- if not $existingSecret }}
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
data:
  password: {{ randAlphaNum 32 | b64enc }}
{{- end }}

This is how charts avoid regenerating a random password on every upgrade - check if the Secret already exists, and only generate one if it doesn't. The catch: lookup only works when Helm is actually talking to a live cluster. helm template renders offline with no API connection, so lookup silently returns an empty result there - your "does it exist" check will always say no. helm install/upgrade work correctly because they run against the real cluster. There is no flag that gives helm template cluster access, so if you need lookup to resolve during a render (CI preview, GitOps diff), use helm install --dry-run=server or helm upgrade --dry-run=server instead: those run the full render against a real API server connection and print the manifests without persisting a release. Don't build hard dependencies on lookup for anything that must render correctly offline in CI.

Whitespace control ({{- / -}}) pitfalls. The hyphen chomps adjacent whitespace, including newlines - {{- eats everything backward to the previous non-whitespace character, -}} eats everything forward. Overusing it collapses YAML onto one line and breaks indentation; underusing it leaves blank lines that are usually harmless but occasionally invalid (a blank line inside a multi-line scalar, for instance). The most common failure mode is chomping across a line you needed:

{{- if .Values.ingress.enabled }}
ingress:
  enabled: true
{{- end }}

vs.

{{- if .Values.ingress.enabled -}}
ingress:
  enabled: true
{{- end }}

The second form chomps the newline after if, which pulls ingress: onto the same line as the (invisible) if output - fine here since the if renders nothing, but a frequent source of "why is my YAML malformed" bugs when the block being conditioned on isn't empty. When debugging whitespace issues, render with helm template --debug and look at the raw output rather than guessing from the template source.

Values architecture

A production values strategy has three layers:

values.yaml           ← chart defaults (committed, no secrets)
values-prod.yaml      ← environment overrides (committed)
values-secrets.yaml   ← sensitive overrides (from Vault/SOPS, not committed)
helm upgrade my-app ./my-chart \
  -f values-prod.yaml \
  -f values-secrets.yaml \
  --set image.tag=v2.3.1

Values from rightmost -f file win. --set wins over all files.

Secrets in values

Never commit plaintext secrets. Common patterns:

  • External Secrets Operator: Helm creates the ExternalSecret CRD, which pulls from Vault/SSM at runtime.
  • SOPS + helm-secrets plugin: helm secrets upgrade ... decrypts on the fly.
  • Sealed Secrets: encrypt with the cluster's public key; commit the SealedSecret manifest.

Install and upgrade workflow

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install my-app ./my-chart -n platform --create-namespace -f values-prod.yaml

# Before upgrading: render and diff
helm diff upgrade my-app ./my-chart -n platform -f values-prod.yaml
helm upgrade my-app ./my-chart -n platform -f values-prod.yaml --atomic --cleanup-on-fail

# Verify, then rollback if needed
helm status my-app -n platform
helm history my-app -n platform
helm rollback my-app 3 -n platform

--atomic rolls back automatically if the upgrade fails. --cleanup-on-fail deletes newly created resources on failure. On Helm 4, --atomic was renamed --rollback-on-failure; the old name still works on helm upgrade with a deprecation warning, so the examples above still run, but new automation should move to the new flag name.

Hooks

Hooks are Jobs (or Pods) that run at defined release lifecycle points.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "-5"          # lower runs first
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["python", "manage.py", "migrate"]
Hook Use case
pre-install / pre-upgrade database migrations, CRD registration
post-install / post-upgrade smoke tests, cache warm-up
pre-delete drain connections, archive data
test helm test triggered checks

hook-delete-policy: before-hook-creation prevents old hook Jobs from blocking subsequent upgrades. hook-succeeded cleans up passing Jobs automatically.

Post-rendering: patching charts you don't control

Sometimes you consume a third-party chart that doesn't expose a value for something you need to change - a missing securityContext, a hardcoded automountServiceAccountToken, a sidecar annotation your platform requires on every pod. Forking the chart is high-maintenance; a post-renderer lets you patch the rendered output without touching the chart source.

# Helm 3
helm install my-app third-party/chart --post-renderer ./patch.sh

On Helm 3, the post-renderer is any executable that reads the fully rendered manifest stream on stdin and writes the patched manifest stream on stdout. In practice this is almost always Kustomize:

#!/usr/bin/env sh
# patch.sh
exec kustomize build --load-restrictor=LoadRestrictionsNone /path/to/kustomization-dir
# kustomization.yaml (the post-render directory)
resources:
  - stdin.yaml   # placeholder; kustomize reads actual input piped by Helm
patches:
  - target:
      kind: Deployment
      name: third-party-app
    patch: |-
      - op: add
        path: /spec/template/spec/securityContext
        value:
          runAsNonRoot: true

There's a subtlety worth internalizing: Helm's --post-renderer operates in the opposite direction from Kustomize's helmCharts: generator. helmCharts: is Kustomize calling out to Helm to expand a chart as one input among its other resources - Kustomize is in the driver's seat. --post-renderer is Helm calling out to an external process (frequently Kustomize) to touch up its own output before applying - Helm is in the driver's seat. Pick based on which tool owns the rest of your pipeline: if your GitOps repo is fundamentally a Kustomize tree, prefer helmCharts:; if it's fundamentally a Helm release with one stubborn upstream chart, prefer --post-renderer. On Helm 3, post-renderers also compose with --post-renderer-args for passing arguments to the renderer script (check helm install --help on your version - the flag was added partway through the 3.x line), and they run on every install, upgrade, and template invocation, so keep them deterministic and side-effect free.

Helm 4: post-renderers are plugins, not bare executables. This is a breaking change from Helm 3, not a deprecation with a grace period - as of Helm 4, helm install --post-renderer ./patch.sh fails with plugin: ... not found; you can no longer point --post-renderer straight at a script or binary. The script itself doesn't need to change, but it has to be wrapped as a Helm plugin (a directory with a plugin.yaml declaring a postrenderer/v1 plugin type) and installed with helm plugin install, then referenced by plugin name instead of path:

# Helm 4
helm plugin install ./my-post-renderer-plugin
helm install my-app third-party/chart --post-renderer my-post-renderer-plugin

If you maintain post-render scripts (including the Kustomize-as-post-renderer pattern below) and need to support both Helm versions during a migration window, expect to ship two invocation paths: the bare-executable form for Helm 3, the plugin-wrapped form for Helm 4.

Helm test

Ship test Jobs inside your chart to verify a deployed release:

# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "my-app.fullname" . }}-test-connection"
  annotations:
    "helm.sh/hook": test
spec:
  restartPolicy: Never
  containers:
    - name: wget
      image: busybox
      command: ['wget', '--spider', 'http://{{ include "my-app.fullname" . }}:{{ .Values.service.port }}/health']
helm test my-app -n platform --logs

Chart testing and linting in CI

helm test verifies a deployed release. Before you ever deploy, a layered CI pipeline should catch problems in the chart itself:

helm lint          → static analysis: required fields, YAML validity, best-practice warnings
helm template       → does it render at all, for every values file you ship
chart-testing (ct)  → lint + install + test against a real (usually kind) cluster, per changed chart
unittest plugin      → assert on rendered output without a cluster at all

helm lint catches the basics - missing Chart.yaml fields, invalid YAML, values referenced but never defaulted:

helm lint ./my-chart -f values-prod.yaml --strict

--strict promotes warnings (like an image tag that looks like latest) to errors, which is what you want gating a merge.

chart-testing (ct), the tool the Helm project itself uses for helm/charts style monorepos, wraps lint + a real helm install against a scratch cluster + helm test, and is smart about only testing charts that changed in a PR:

ct lint --config ct.yaml
ct install --config ct.yaml   # spins up kind, installs each changed chart, runs helm test

This is the closest thing to an integration test for a chart - it catches problems helm lint structurally cannot, like a Job hook that never reaches Completed or a Service selector that doesn't match any Pod.

The helm-unittest plugin takes the opposite approach: no cluster, pure rendered-output assertions, fast enough to run on every commit. Tests live alongside the chart and assert against helm template output:

# tests/deployment_test.yaml
suite: test deployment
templates:
  - deployment.yaml
tests:
  - it: should set replica count from values
    set:
      replicaCount: 3
    asserts:
      - equal:
          path: spec.replicas
          value: 3
  - it: should not mount a volume when persistence is disabled
    set:
      persistence.enabled: false
    asserts:
      - notExists:
          path: spec.template.spec.volumes
helm unittest ./my-chart

Golden-file snapshot testing is the same idea taken further: render the chart with a fixed set of values, commit the output, and fail CI if a future change produces a diff you didn't review and re-approve:

helm template my-app ./my-chart -f values-prod.yaml > testdata/golden/prod.yaml
# in CI:
helm template my-app ./my-chart -f values-prod.yaml | diff testdata/golden/prod.yaml -

Golden files catch the class of bug unit assertions miss - an unrelated template change that quietly reformats indentation or drops a label across the entire rendered manifest, not just the field a targeted assertion checks. The tradeoff is churn: every intentional change requires regenerating and re-committing the golden file, so reserve this for charts stable and important enough to be worth the review overhead (platform base charts, not every microservice chart).

A reasonable CI gate order: helm lint --stricthelm unittest → golden-file diff → ct install on changed charts only (it's the slowest, so don't run it on every chart in a monorepo, just the ones touched by the PR).

OCI registry support and chart repository mechanics

Charts can be stored in OCI registries (Docker Hub, ECR, Artifact Registry, Harbor):

helm package ./my-chart
helm push my-chart-1.4.2.tgz oci://registry.example.com/charts

helm pull oci://registry.example.com/charts/my-chart --version 1.4.2
helm install my-app oci://registry.example.com/charts/my-chart --version 1.4.2

OCI registries don't need helm repo add. Use your existing container registry for chart storage and your existing registry credentials - no separate chart-hosting infrastructure, and the same image-pull auth (IRSA, Workload Identity, imagePullSecrets) that already governs your containers governs your charts.

The classic repo model, for comparison

The original (still widely used) distribution mechanism is an HTTP index, not a registry. helm repo add points at a URL serving an index.yaml:

# index.yaml (generated, not hand-written)
apiVersion: v1
entries:
  my-app:
    - version: 1.4.2
      appVersion: "2.0.1"
      created: "2026-01-15T10:00:00Z"
      urls:
        - https://charts.example.com/my-app-1.4.2.tgz
      digest: sha256:...

Publishing means packaging, regenerating the index, and re-hosting it:

helm package ./my-chart -d ./repo
helm repo index ./repo --url https://charts.example.com --merge ./repo/index.yaml
# upload ./repo/*.tgz and the updated index.yaml to your static host

helm repo index --merge folds new entries into an existing index rather than overwriting history - forgetting --merge is a classic way to accidentally delete every older chart version from the index in one publish.

Why the ecosystem is moving to OCI

The index.yaml model has structural weaknesses OCI doesn't share: it's a single flat file every client re-downloads and re-parses on helm repo update, it has no native content-addressability or layer dedup, and it requires bespoke static hosting plus a separate signing story from your container images. OCI gives charts the same distribution model as images - content-addressed blobs, registry-native auth, cosign/sigstore compatibility, and one piece of infrastructure (the registry) for both artifacts. Most major chart publishers (Bitnami, the Kubernetes project's own charts, most CNCF projects) now publish to OCI as the primary channel, with classic HTTP repos kept around for compatibility. If you're standing up chart distribution today, default to OCI and only stand up an index.yaml repo if you have consumers who can't yet pull OCI artifacts.

Library charts

A library chart (type: library in Chart.yaml) exports only named templates - no manifests rendered directly. Use it to share _helpers.tpl patterns across charts in a monorepo.

# Chart.yaml for an application chart
dependencies:
  - name: my-lib
    version: "0.1.0"
    repository: "oci://registry.example.com/charts"
# In application template
{{- include "my-lib.deployment" . }}

Dependency management

helm dependency update ./my-chart   # resolve and download to charts/
helm dependency build ./my-chart    # rebuild from Chart.lock (CI usage)

Pin dependencies to exact versions or SemVer ranges in Chart.yaml. Use condition: to make sub-charts opt-in via values.

Umbrella charts, sub-chart addressing, and global values

An umbrella (parent) chart has no templates of its own - or only a handful - and exists mainly to compose several sub-charts into one installable, versioned unit. This is how many platform teams ship "install our whole stack" as a single helm install.

# Chart.yaml
apiVersion: v2
name: platform
version: 3.2.0
dependencies:
  - name: api
    version: "1.4.x"
    repository: "oci://registry.example.com/charts"
  - name: worker
    version: "1.1.x"
    repository: "oci://registry.example.com/charts"
  - name: redis
    version: "18.x.x"
    repository: "oci://registry-1.docker.io/bitnamicharts"
    condition: redis.enabled

The parent's values.yaml addresses each sub-chart by name as a top-level key, and every value under that key is passed down as if it were the sub-chart's own values.yaml:

# values.yaml (parent)
api:
  replicaCount: 3
  image:
    tag: "2.0.1"
worker:
  replicaCount: 5
redis:
  enabled: true
  auth:
    enabled: true

Override a nested sub-chart value from the CLI with dotted addressing:

helm upgrade platform ./platform --set api.replicaCount=5 --set worker.image.tag=v1.2.0

global: is the escape hatch for values every sub-chart (and the parent) needs to agree on - an image pull secret name, a common label, a cluster region. Anything under global: in the parent's values is merged into .Values.global for every sub-chart automatically, without the parent needing to repeat it under each sub-chart's key:

global:
  imageRegistry: registry.example.com
  imagePullSecrets:
    - platform-registry-creds
# inside a sub-chart's template
image: "{{ .Values.global.imageRegistry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}"

Sub-charts must explicitly reference .Values.global.X in their own templates for this to have any effect - global: doesn't silently override sub-chart-local values with the same name, it's a separate namespace they opt into reading. A common mistake is assuming a top-level parent value like imageRegistry: (not nested under global:) will reach into a sub-chart; it won't, because sub-charts only ever see their own namespaced values plus whatever landed under .Values.global.

Debugging a failed release: a playbook

A stuck or failed release is where Helm's abstractions leak the most. Work through this in order.

1. What did Helm think it was applying?

helm get manifest my-app -n platform --revision 12

This is the rendered YAML for a specific revision, reconstructed from the release Secret - not necessarily what's live now. helm get manifest with no --revision shows the current recorded revision's manifest, which after a failed non-atomic upgrade can differ significantly from what's actually running in the cluster.

2. What's actually live?

kubectl get all -n platform -l app.kubernetes.io/instance=my-app
kubectl diff -f <(helm get manifest my-app -n platform)

kubectl diff against the recorded manifest is the fastest way to see drift - resources someone hand-edited, or resources the last upgrade never got to.

3. Orphaned resources after a failed atomic rollback. --atomic rolls back to the previous revision on failure, but rollback is itself a helm upgrade under the hood, and it can also fail or partially apply - particularly for resources that Helm considers immutable-on-update (a Job's spec.selector, some StatefulSet fields) where the rollback's own patch is rejected by the API server. The result: resources created by the failed forward upgrade that the rollback couldn't clean up, sitting orphaned in the namespace, not referenced by the release Helm now considers current. helm get manifest won't show them because they're not part of any revision Helm currently tracks as active. Find them the unglamorous way - diff kubectl get all -n platform against helm get manifest, or look for resources with the release's labels but a helm.sh/chart annotation value that doesn't match helm history's current entry.

4. What does --wait actually wait for? --wait (implied by --atomic) blocks until Helm considers every resource in the release "ready," and readiness is kind-specific and easy to misjudge the timeout for:

Kind "Ready" means
Deployment status.availableReplicas == desired, per minReadySeconds
StatefulSet all replicas at currentRevision == updateRevision, ready
Job status.succeeded reaches completions
Service (type LoadBalancer) status.loadBalancer.ingress is populated
PersistentVolumeClaim phase is Bound

--timeout (default 5m) applies to the whole operation, not per-resource - a chart with a slow-provisioning LoadBalancer Service and a slow-starting StatefulSet share one clock. A release that fails with "context deadline exceeded" isn't necessarily broken; it may just need --timeout 10m. Don't reflexively raise the timeout without checking why something was slow first (an unschedulable pod will never become ready no matter how long you wait, and a longer timeout just delays finding that out).

5. History is your audit log. helm history my-app -n platform shows every revision with its status (deployed, failed, superseded, rolled-back) - read it before assuming the current state matches the latest revision number; a failed revision still increments the counter.

Use the helm-diff plugin in CI to produce a diff between the current release and what helm upgrade would apply. This is the best way to catch unintended template changes before deployment.

helm template my-app ./my-chart -f values-prod.yaml        # render locally
helm template my-app ./my-chart -f values-prod.yaml --debug # show computed values
helm get manifest my-app -n platform                        # what's live in cluster
helm get values my-app -n platform                          # what values are active

Chart provenance, signing, and supply-chain hygiene

Charts pulled from public repositories are executable configuration for your cluster - a malicious or compromised chart can create arbitrary resources, including ones with cluster-admin RBAC. Treat chart supply chain with the same seriousness as image supply chain.

Signing a chart you publish:

helm package ./my-chart --sign --key 'my-signing-key' --keyring ~/.gnupg/secring.gpg
# produces my-chart-1.4.2.tgz and my-chart-1.4.2.tgz.prov

The .prov (provenance) file contains a signed hash of the chart archive and a copy of Chart.yaml. Anyone installing the chart can verify it wasn't tampered with after signing:

helm verify my-chart-1.4.2.tgz --keyring ~/.gnupg/pubring.gpg
helm install my-app ./my-chart-1.4.2.tgz --verify --keyring ~/.gnupg/pubring.gpg

For OCI-distributed charts, the modern equivalent is cosign sign/cosign verify against the chart's OCI artifact, which fits the same keyless/Sigstore-based signing flow increasingly used for container images - one signing story for both artifact types rather than Helm's GPG-based provenance and a separate image-signing pipeline.

Supply-chain concerns to actually check, not just sign for:

  • Pin exact chart versions. version: "12.x.x" in a dependency is convenient but means your next helm dependency update can silently pull in a new minor version with different defaults, new RBAC, or a compromised release. Pin exact versions for anything security-sensitive and bump deliberately.
  • Review before first install, not after. helm template a chart from an unfamiliar repository and read the rendered RBAC, hostNetwork/hostPath usage, and any hook Jobs before installing - hooks in particular run with whatever ServiceAccount the release uses and are easy to overlook in a large chart.
  • Mirror charts you depend on critically. Public chart repositories go away, get renamed, or get compromised. For anything load-bearing, mirror the chart (and its OCI digest, not just its tag) into a registry you control rather than depending on helm dependency update reaching the internet at deploy time.
  • Chart.lock is your reproducibility guarantee, treat it like a lockfile. helm dependency build installs exactly what Chart.lock records (including digests); helm dependency update recomputes it. Commit Chart.lock and use build in CI, not update, the same way you'd use npm ci over npm install.

Common production mistakes

Mistake Fix
Bumping appVersion without bumping version Always increment version when chart content changes
--set in CI without a diff step Use helm diff to catch unexpected changes
Forgetting --atomic on upgrade Cluster can end up with partial upgrade if pods fail
Using latest as the image tag Use explicit digest or immutable tag; appVersion should match
Storing release secrets in default namespace Deploy to the namespace you intend; secrets stay there
Assuming lookup works in helm template It never does - helm template has no cluster connection. Use helm install/upgrade --dry-run=server to render with lookups, and don't depend on lookup for offline CI rendering
helm dependency update in CI instead of build update re-resolves ranges and can silently pull a new sub-chart version; use build against a committed Chart.lock
No helm lint/unittest gate before merge Broken templates reach helm upgrade instead of failing fast in CI
Unpinned chart version ranges (12.x.x) on security-sensitive dependencies Pin exact versions; bump deliberately after review