../

Helm

Packaging, templating and releasing Kubernetes apps with Helm 4 (v4.3). Charts written for Helm 3 (apiVersion: v2) install unchanged. Objects and kubectl are covered in Kubernetes.

Concepts

TermMeaning
Chartversioned package: Chart.yaml, default values.yaml, templates/
Releaseone install of a chart, named and scoped to a namespace
Revisionnumber bumped by every install, upgrade and rollback; stored as Secret sh.helm.release.v1.NAME.vN
Valuesconfig tree merged from defaults, -f files and --set flags, then fed to the templates
RepositoryHTTP server with an index.yaml and .tgz charts; needs helm repo add
OCI registrycharts stored as OCI artifacts, addressed as oci://host/path/chart
Library charttype: library: named templates only, renders no objects
Subcharta dependency, vendored into charts/
Hookobject run at a lifecycle point (pre-upgrade, post-install, test, …)
values.yaml ─┐
-f, --set  ──┼─► merge ─► templates/ ─► YAML ─► schema + OpenAPI
charts/    ──┘                                   check
                        ┌──────────────────────────┘
                        ▼
      server-side apply ─► --wait (kstatus) ─► store revision

Helm 4 vs Helm 3

Helm 4.0.0 shipped on 2025-11-12, the first major release in six years. Helm 3 gets bug fixes until 2026-07-08 and security fixes until 2026-11-11.

AreaHelm 3Helm 4
Applying objectsclient-side three-way mergeserver-side apply for new releases; --server-side=auto (upgrade default) keeps a release's previous method
Field conflictsoverwrittenreported; --force-conflicts takes ownership
Waiting--wait polls a fixed set of kinds--wait means watcher (kstatus, any kind); --wait=legacy is the old logic; default hookOnly
Roll back on failure--atomic--rollback-on-failure (implies --wait=watcher); --atomic is a deprecated alias
Force--force--force-replace
Post-renderersany executablemust be a plugin: --post-renderer <plugin-name>
Pluginsexec onlynew system with an optional WebAssembly runtime; CLI, getter and post-renderer types; signature checked by default
helm registry loginURL acceptedhost name only: helm registry login ghcr.io
OCItagtag or digest: oci://…/api@sha256:…
Chart APIv2v2 unchanged; experimental v3 behind HELM_EXPERIMENTAL_CHART_V3=1
Caching, logsrepo cachecontent-addressed chart cache; slog logging in the SDK
Deprecatedhelm template --hide-notes, --render-subchart-notes (removed in v5)

Releases installed by Helm 3 upgrade in place: the storage format is the same, and they keep client-side apply until you pass --server-side=true.

CLI

CommandDoes
helm create apiscaffold a chart (Deployment, Service, Ingress, HTTPRoute, HPA, tests)
helm install api ./api -n web --create-namespacefirst install
helm upgrade --install api ./api -n web -f values/prod.yamlinstall or upgrade; idempotent, use it in CI
helm rollback api 7back to revision 7 (omit the number for the previous one)
helm uninstall api -n web --keep-historydelete objects, keep history for a later rollback
helm list -Areleases in all namespaces, any status; filter with --failed, --pending, --deployed
helm history apirevisions with status, chart, app version and description
helm status apistate, resources and NOTES
helm get values apivalues you supplied; -a for all computed values
helm get manifest api --revision 6rendered YAML as it was applied
helm get notes api, get hooks, get metadataother parts of a release
helm template api ./api -f values/prod.yamlrender locally, no cluster needed
helm lint ./api --strict --with-subchartsstatic checks; --strict fails on warnings
helm package ./api --version 1.2.0 --app-version 1.4.2build api-1.2.0.tgz
helm push api-1.2.0.tgz oci://ghcr.io/acme/chartspublish to a registry
helm pull oci://ghcr.io/acme/charts/api --version 1.2.0 --untardownload and unpack
helm repo add grafana https://grafana.github.io/helm-chartsregister an HTTP repo
helm repo updaterefresh repo indexes
helm search repo grafana/loki --versionssearch added repos; helm search hub for Artifact Hub
helm dependency update ./apiresolve dependencies into charts/ and write Chart.lock
helm dependency build ./apirebuild charts/ exactly from Chart.lock
helm show values grafana/lokidefault values; also show chart, readme, crds, all
helm test api --logsrun test hooks, print their logs
helm plugin install, list, updatemanage plugins
FlagUse
-n web, --kube-context prodnamespace and kubeconfig context
--version 1.2.0chart version or range (^1.2); latest if omitted
-f file.yaml, --set k=vvalues (see Values & overrides)
--wait, --timeout 10mwait for readiness (kstatus); per-operation timeout, default 5m
--wait-for-jobswith --wait, also wait for Jobs to complete
--rollback-on-failurefailed install is uninstalled, failed upgrade rolled back
--cleanup-on-faildelete objects created by a failed upgrade
--dry-run=serverrender with cluster access (lookup, real capabilities), persist nothing
--reset-then-reuse-valueschart defaults, then last release's values, then your overrides
--take-ownershipadopt existing objects not created by this release
--skip-crds, --no-hooksskip crds/ or hooks

Chart structure

chart layout
api/Chart.yaml          # metadata and dependenciesChart.lock          # pinned dependency versionsvalues.yaml         # defaultsvalues.schema.json  # JSON Schema for values.helmignore         # excluded from the packageREADME.mdcharts/             # dependency .tgz filescrds/               # installed first, never templatedtemplates/_helpers.tpl    # named templates; _* never rendereddeployment.yamlservice.yamlingress.yamlNOTES.txt       # printed after install and upgradetests/test-connection.yaml
Chart.yaml
apiVersion: v2                # v2 = Helm 3 and 4 charts
name: api
description: Acme API
type: application             # or library
version: 1.2.0                # chart SemVer; bump on change
appVersion: "1.4.2"           # app version; quote it
kubeVersion: ">=1.33.0-0"     # -0 lets pre-releases match
keywords: [api, acme]
home: https://github.com/acme/api
sources: [https://github.com/acme/api]
maintainers:
  - name: Acme Platform
    email: platform@acme.dev
icon: https://acme.dev/icon.svg
dependencies:
  - name: common
    version: ~2.3.0
    repository: oci://ghcr.io/acme/charts
annotations:
  artifacthub.io/license: MIT

version is the chart's own version and must change whenever the chart does. appVersion is informational, conventionally the default image tag. deprecated: true hides a chart from search.

Values & overrides

values.yaml
replicaCount: 2
image:
  repository: ghcr.io/acme/api
  tag: ""                     # empty = .Chart.AppVersion
  pullPolicy: IfNotPresent
service:
  port: 80
  targetPort: 3000
env: []
resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits: { memory: 256Mi }
config:
  LOG_LEVEL: info
ingress:
  enabled: false
  className: ""
  annotations: {}
  hosts: []
  tls: []

Precedence, lowest to highest (maps merge deeply; lists are replaced whole):

#Source
1a subchart's own values.yaml
2the parent chart's values.yaml (under the subchart's key)
3-f / --values files, left to right
4--set-json, then --set, then --set-string, then --set-file, then --set-literal

Flag groups apply in that fixed order whatever their position on the command line; within one flag, later occurrences win.

FlagExampleResult
--set--set image.tag=1.4.2nested key; only true, false, null and integers are converted, the rest stays a string
--set list--set 'args={serve,--port=3000}'args: [serve, --port=3000]
--set index--set 'env[0].name=MODE,env[0].value=prod'list item fields
--set dotted key--set 'podAnnotations.prometheus\.io/scrape=true'backslash escapes the dot
--set null--set ingress.annotations.foo=nulldeletes a key set by defaults
--set-string--set-string image.tag=110stays "110", not the integer 110
--set-json--set-json 'tolerations=[{"key":"gpu"}]'JSON values
--set-file--set-file appConfig=./app.tomlthe file's content as a string
--set-literal--set-literal password='a,b=c\d'no parsing of commas or escapes
On upgradeValues used
defaultchart defaults + this command's -f/--set only
--reuse-valueslast release's values + overrides; ignores new chart defaults
--reset-then-reuse-valuesnew chart defaults, then last release's values, then overrides

Templating

Templates are Go text/template plus the Sprig library, rendered to YAML before any parsing.

ObjectHolds
.Valuesmerged values
.Release.Name, .Namespace, .Revision, .IsInstall, .IsUpgrade, .Service (Helm)
.ChartChart.yaml fields, capitalized: .Name, .Version, .AppVersion
.Capabilities.KubeVersion.Version, .APIVersions.Has "gateway.networking.k8s.io/v1"
.Filesnon-template files: .Get "conf/app.toml", .Glob "conf/*", .AsConfig, .AsSecrets
.Template.Name (current file), .BasePath (the chart's templates dir)

helm template has no cluster, so .Capabilities is faked; pass --kube-version and --api-versions to control it.

SyntaxMeans
{{ .Values.x }}print a value
{{- x }}, {{ x -}}trim whitespace and newlines to the left, right
{{/* note */}}comment, prints nothing
x | f apipeline: calls f a x, the piped value goes last
$v := .Values.x, $v = 2declare, reassign a variable
$the root context, reachable inside range and with
if, else if, else, endempty is false: false, 0, "", nil, empty list or map
with .Values.xrebinds . to x; skipped when x is empty
range .Values.listloop; . is the item; range $i, $v := … for index
range $k, $v := .Values.maploop over a map, keys sorted
and, or, not, eq, ne, lt, gtprefix functions: if and .a (eq .b "x")
FunctionExample
default.Values.image.tag | default .Chart.AppVersion
requiredrequired "image.repository is required" .Values.image.repository
quote, squote{{ .Values.tag | quote }}: always quote strings that look like numbers
toYaml, nindenttoYaml .Values.resources | nindent 12: newline, then indent
indentlike nindent without the leading newline
toJson, fromYaml, fromJson, toTomlconvert structures
includeinclude "api.labels" . | nindent 4: a named template as a string
tpltpl .Values.hostTemplate .: render a value as a template
printfprintf "%s-%s" .Release.Name "db"
trunc, trimSuffixtrunc 63 | trimSuffix "-": DNS-safe names
lower, upper, replace, trim, contains, hasPrefixstrings
b64enc, b64dec, sha256sumencoding and hashing
dict, list, merge, hasKey, dig, pluck, keysbuild and read maps
ternaryternary "Always" "IfNotPresent" .Values.dev
semverComparesemverCompare ">=1.33-0" .Capabilities.KubeVersion.Version
lookuplookup "v1" "Secret" .Release.Namespace "db": live object; empty with helm template
failabort rendering with a message
includetemplate
Returnsa string you can pipewrites straight to output
| nindent 4worksimpossible
Usealwayslegacy charts only
scoping.yaml
metadata:
  {{- with .Values.podAnnotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  containers:
    - name: api
      env:
        {{- range $k, $v := .Values.config }}
        - name: {{ $k }}
          value: {{ $v | quote }}
        {{- end }}
        - name: RELEASE
          value: {{ $.Release.Name }}   # $ = root

{{- eats the newline before the tag, so a tag on its own line leaves no blank line. Most "YAML parse error" messages are a wrong nindent count.

Named templates

Names are global across a chart and all its subcharts, so prefix them with the chart name.

templates/_helpers.tpl
{{- define "api.name" -}}
{{- default .Chart.Name .Values.nameOverride
  | trunc 63 | trimSuffix "-" }}
{{- end }}
 
{{- define "api.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name
  | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
 
{{- define "api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
 
{{- define "api.labels" -}}
{{ include "api.selectorLabels" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
 
{{- define "api.image" -}}
{{- $tag := .Values.image.tag | default .Chart.AppVersion }}
{{- printf "%s:%s" .Values.image.repository $tag }}
{{- end }}

Selector labels never include the version: selectors are immutable, so a changing label there breaks every upgrade. To pass more than one argument, build a map: include "api.container" (dict "root" $ "c" .), then read .root.Values and .c inside.

Hooks & tests

HookRuns
pre-install, post-installbefore any object is created, after all are ready
pre-upgrade, post-upgradearound an upgrade
pre-rollback, post-rollbackaround a rollback
pre-delete, post-deletearound an uninstall
testonly on helm test
AnnotationValues
helm.sh/hookcomma-separated hook names
helm.sh/hook-weightstring integer; lower runs first (default "0")
helm.sh/hook-delete-policybefore-hook-creation (default), hook-succeeded, hook-failed
helm.sh/resource-policy: keepnot a hook: leave this object behind on uninstall (PVCs, Secrets)
templates/migrate-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "api.fullname" . }}-migrate
  annotations:
    helm.sh/hook: pre-install,pre-upgrade
    helm.sh/hook-weight: "0"
    helm.sh/hook-delete-policy: >-
      before-hook-creation,hook-succeeded
spec:
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: {{ include "api.image" . }}
          command: [bun, run, db:migrate]
          envFrom:
            - secretRef:
                name: {{ .Values.db.existingSecret }}

Hooks aren't part of the release's managed objects: helm uninstall leaves them unless a delete policy removes them. A pre-install hook runs before the chart's own Secrets and ConfigMaps exist, so reference objects created outside the release (or make those hooks too, with a lower weight).

templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: {{ include "api.fullname" . }}-test
  annotations:
    helm.sh/hook: test
    helm.sh/hook-delete-policy: hook-succeeded
spec:
  restartPolicy: Never
  containers:
    - name: wget
      image: busybox:1.37
      command: [wget, -qO-]
      args:
        - http://{{ include "api.fullname" . }}/healthz

Dependencies & subcharts

Chart.yaml (excerpt)
dependencies:
  - name: postgresql
    version: ~16.2.0          # SemVer range
    repository: oci://ghcr.io/acme/charts
    condition: postgresql.enabled
  - name: worker
    alias: emails             # values key becomes emails:
    version: 1.x
    repository: file://../worker
    tags: [workers]
FieldDoes
repositoryhttps://… repo URL, oci://…, file://../path, or @name of an added repo
conditionvalues path that enables the subchart (first one that exists wins)
tagsenable groups at once: --set tags.workers=false
aliasinstall the same chart twice under different keys
import-valuescopy a child's exports or values up into the parent
values.yaml (parent)
global:                  # all charts: .Values.global
  imageRegistry: ghcr.io/acme
postgresql:              # the subchart's .Values
  enabled: true
  auth: { database: app }
emails:
  replicaCount: 2

A subchart sees only its own key plus global; it can't read the parent's values. Named templates, though, are shared across all of them. Commit Chart.lock; charts/*.tgz is usually git-ignored and restored with helm dependency build.

Schema validation

values.schema.json is checked on install, upgrade, lint and template, for the chart and each subchart. --skip-schema-validation turns it off.

values.schema.json
{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image", "service"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 1 },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string", "minLength": 1 },
        "tag": { "type": "string" },
        "pullPolicy": {
          "enum": ["Always", "IfNotPresent", "Never"]
        }
      }
    },
    "service": {
      "type": "object",
      "properties": {
        "port": {
          "type": "integer",
          "minimum": 1,
          "maximum": 65535
        }
      }
    }
  }
}

"tag": { "type": "string" } catches the classic tag: 1.10 in a values file (a YAML float) at install time, instead of pulling image api:1.1.

Secrets

ApproachHowTrade-off
Values from CI-f a file or --set-file from the CI secret storesimple; values are in the release Secret, readable by anyone who can read Secrets in the namespace
helm-secrets + SOPSencrypted secrets.yaml in Git; -f secrets://values/secrets.yamldecrypted at deploy time; age, KMS or PGP keys
External Secrets Operatorchart ships an ExternalSecret; ESO pulls from Vault, AWS, GCP, Azure, 1Passwordno secret values pass through Helm
Sealed Secretschart ships a SealedSecret encrypted for the cluster's keyGitOps-friendly; re-seal per cluster
Existing SecretexistingSecret: api-db value; chart only references itsecret managed elsewhere
lookup + randAlphaNumgenerate once, reuse the live value on upgradebreaks with helm template and Argo CD (lookup returns nothing)
templates/externalsecret.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: {{ include "api.fullname" . }}-db
spec:
  refreshInterval: 1h
  secretStoreRef: { kind: ClusterSecretStore, name: vault }
  target:
    name: {{ include "api.fullname" . }}-db
  data:
    - secretKey: DATABASE_URL
      remoteRef: { key: apps/api, property: database_url }

OCI registries

echo "$GHCR_TOKEN" | helm registry login ghcr.io \
  -u "$GH_USER" --password-stdin
 
helm package ./api                  # api-1.2.0.tgz
helm push api-1.2.0.tgz oci://ghcr.io/acme/charts
# stored as ghcr.io/acme/charts/api:1.2.0; prints the digest
 
helm show values oci://ghcr.io/acme/charts/api \
  --version 1.2.0
helm upgrade --install api oci://ghcr.io/acme/charts/api \
  --version 1.2.0 -n web
 
# Helm 4: pin by digest
helm install api \
  oci://ghcr.io/acme/charts/api@sha256:9f86d0…
RuleDetail
namingchart name becomes the repository, chart version the tag
+ in versionsstored as _ in the tag (tags can't contain +)
no repo add or searchaddress charts by full oci:// reference
dependenciesrepository: oci://ghcr.io/acme/charts (without the chart name)
registriesGHCR, ECR, Artifact Registry, ACR, Docker Hub, Harbor, zot

GitOps & tooling

ToolModelNotes
helmfilehelmfile.yaml lists releases, values and environments; helmfile diff, helmfile applydeclarative wrapper around the Helm CLI; uses helm-diff
Argo CDApplication with source.chart or a chart path plus helm.valueFilesrenders with helm template and applies itself: helm list shows nothing, lookup is empty, hooks map to sync phases
FluxHelmRelease + HelmRepository or OCIRepositoryhelm-controller runs real Helm installs and upgrades; releases visible to helm list
Renovatebumps chart versions in Chart.yaml, helmfile and Flux or Argo manifestspairs with any of the above
helmfile.yaml
repositories:
  - name: grafana
    url: https://grafana.github.io/helm-charts
releases:
  - name: loki
    namespace: observability
    chart: grafana/loki
    version: ~6.40.0
    values: [values/loki.yaml]
  - name: api
    namespace: web
    chart: oci://ghcr.io/acme/charts/api
    version: 1.2.0
    values: [values/prod.yaml]

Debugging

CommandShows
helm template api ./api -f v.yaml --debugrender; on a YAML error prints the broken output
helm template api ./api -s templates/deployment.yamlone template only
helm install api ./api --dry-run=serverrender with cluster access: real .Capabilities, working lookup; nothing stored
helm lint ./api --strictchart and values problems
helm get manifest api | kubectl diff -f -drift between the release and the live cluster
helm history api, helm status apiwhich revision failed and why
helm get values api -a --revision 5the exact values a revision used
kubectl get secret -n web -l owner=helm,name=apirelease records, one per revision
ErrorFix
another operation (install/upgrade/rollback) is in progressa run was killed mid-way; helm rollback api to the last deployed revision
YAML parse error on api/templates/…indentation: check nindent counts with --debug
nil pointer evaluating interface {}.xparent key missing: guard with with, or use dig "a" "b" "" .Values
exists and cannot be imported into the current releaseobject made outside Helm: --take-ownership, or add meta.helm.sh/release-name and release-namespace annotations
field is immutableselector labels or StatefulSet volumeClaimTemplates changed; delete and recreate the object
conflict with "kubectl-client-side-apply"another field manager owns it; fix the source or --force-conflicts
value seems ignoredwrong path or subchart key: compare helm get values api -a with helm show values
image tag 1.1 instead of 1.10unquoted 1.10 in YAML is a float: quote it, type it in the schema, | quote in the template

Recipes

Minimal app chart

A Deployment template built on the helpers above, driven by the values.yaml shown earlier.

templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "api.fullname" . }}
  labels: {{- include "api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "api.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: {{ include "api.image" . }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
          env: {{- toYaml .Values.env | nindent 12 }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Conditional Ingress

Render an Ingress only when enabled, with $ to reach the root inside range.

templates/ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "api.fullname" . }}
  labels: {{- include "api.labels" . | nindent 4 }}
  {{- with .Values.ingress.annotations }}
  annotations: {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  tls: {{- toYaml .Values.ingress.tls | nindent 4 }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "api.fullname" $ }}
                port: { name: http }
    {{- end }}
{{- end }}

Roll Pods when config changes

A changed ConfigMap doesn't restart Pods; hashing it into a Pod annotation does.

templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "api.fullname" . }}
data:
  {{- range $k, $v := .Values.config }}
  {{ $k }}: {{ $v | quote }}
  {{- end }}
templates/deployment.yaml (excerpt)
  template:
    metadata:
      annotations:
        checksum/config: {{ include
          (print $.Template.BasePath "/configmap.yaml") .
          | sha256sum }}

To restart on every upgrade regardless, use rollme: {{ randAlphaNum 5 | quote }} instead.

Per-environment values files

Keep defaults in values.yaml and only the differences per environment; CI supplies the tag.

values/prod.yaml
# helm upgrade --install api ./api -n prod \
#   -f values/prod.yaml --set-string image.tag="$SHA" \
#   --rollback-on-failure --timeout 10m
replicaCount: 4
resources:
  requests: { cpu: 250m, memory: 512Mi }
  limits: { memory: 512Mi }
config:
  LOG_LEVEL: warn
ingress:
  enabled: true
  className: traefik
  hosts:
    - host: api.acme.dev
  tls:
    - secretName: api-tls
      hosts: [api.acme.dev]

Umbrella chart

Install several services as one release with shared globals (helm dependency update shop first).

shop/Chart.yaml
apiVersion: v2
name: shop
version: 0.3.0
dependencies:
  - name: api
    version: ~1.2.0
    repository: oci://ghcr.io/acme/charts
  - name: web
    version: ~2.0.0
    repository: file://../web
  - name: worker
    alias: emails
    version: ~1.0.0
    repository: oci://ghcr.io/acme/charts
    condition: emails.enabled
shop/values.yaml
global:
  imageRegistry: ghcr.io/acme
api:
  replicaCount: 3
emails:
  enabled: true

Render and diff before upgrading

Review exactly what an upgrade will change, locally or as a CI gate.

# helm-diff; Helm 4 checks the plugin's GPG signature
curl -sL https://github.com/databus23.gpg | gpg --import
u=https://github.com/databus23/helm-diff/releases/latest
helm plugin install \
  "$u/download/helm-diff-darwin-arm64.tgz"
 
# live release vs. new chart + values; exit 2 on changes
helm diff upgrade api ./api -n prod \
  -f values/prod.yaml --context 3 --detailed-exitcode
 
# no cluster: compare two renders
diff -u \
  <(helm template api ./api -f values/staging.yaml) \
  <(helm template api ./api -f values/prod.yaml)

References