Helm Chart Migration to Helm 4: 13 Steps, 60 Min [2026]

Helm 3 stops receiving security fixes on February 10, 2027, and the project already shipped its last Helm 3 feature release on September 9, 2026. Dev News Daily framed the squeeze on October 16 under the headline Helm 3.22.1 ships with under four months of security support left. If your helm chart pipeline still calls a Helm 3 binary, the countdown is already running.

This tutorial walks through a full Helm chart migration to Helm 4 in 13 steps. It takes about 60 minutes on a laptop. You will install Helm 4 beside Helm 3, build a legacy chart, prove that both versions render identical manifests, adopt a live release, translate deprecated flags, add a values schema and tests, publish to an OCI registry, rehearse a failed upgrade, and lock the whole thing into CI. If you have never shipped a chart at all, start with our walkthrough on how to deploy applications with Kubernetes and Helm and come back here once you have a release running.

Everything runs locally on a kind cluster plus a throwaway container registry, so it costs nothing and breaks nothing. By the end you will have a working project directory, a Makefile that reproduces every check, and a GitHub Actions workflow that pins Helm 4 and publishes a chart artifact on every tag.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Helm 3 Ends February 10, 2027: The Migration Clock

The timeline is short and the project has been consistent about it. Helm v4.0.0 shipped on November 12, 2025, during the Helm 4 presentation at KubeCon + CloudNativeCon, and the maintainers called it the first new major version of Helm in six years. The Helm 3 end-of-life notice sets a final, limited feature release for September 9, 2026. That release carries only Kubernetes client-library updates so Helm 3 can talk to newer clusters. Security patches continue until February 10, 2027, a date the project pushed back from November 2026. After that, no Helm 3 release of any kind will ship, not even for a critical CVE.

Dev News Daily and the project’s release notes also say no further Helm 3 minor releases are planned. Reportedly, Helm 3.22.2 and Helm 4.3.2 are slated for November 11, 2026, but treat those dates as plans until the tags exist. In short, you have roughly three and a half months of runway from today, October 22, 2026.

MilestoneDateWhat it means for your pipeline
Helm v4.0.0 releasedNovember 12, 2025First major Helm release in 6 years, announced at KubeCon + CloudNativeCon
Final Helm 3 feature releaseSeptember 9, 2026Last Helm 3 minor, limited to Kubernetes client-library updates
Helm 3 security fixes endFebruary 10, 2027No updates of any kind after this date, including CVE patches
Kubernetes 1.37 “Garhwal” releasedAugust 26, 2026Current upstream minor, so new clusters will ship against it

The Kubernetes side moves too. Kubernetes 1.37, codenamed Garhwal, landed on August 26, 2026, and saaro’s October 21 analysis tracks how EKS, AKS and GKE roll it out at different speeds. A Helm 3 binary compiled against old client libraries will keep working against many clusters for a while. But once the project stops patching it, you inherit every future bug and every future CVE yourself.

The maintainers say they expect most existing Helm 3 charts, releases and workflows to keep working under Helm 4. That is a stated expectation, not a guarantee for every chart, which is why this tutorial treats “does my chart render the same?” as a test you run rather than an assumption you make.

Prerequisites and Versions You Need

You need a Linux or macOS shell (WSL2 works on Windows), about 4 GB of free RAM for kind, and 20 minutes of patience for the first image pulls. The versions below are the ones this guide pins. Where it says “latest stable,” install whatever your package manager offers today.

ToolVersion used in this guideWhy it matters
Helm 44.x line, pinned to v4.3.1 in the commandsThe migration target
Helm 33.x line, pinned to v3.22.1 in the commandsBaseline for comparison only
Kubernetes (kind node)1.37 or the newest your kind release supportsMatches what managed clouds are adopting
kubectlWithin one minor version of the clusterKubernetes version-skew policy
Docker EngineLatest stablekind nodes and the local registry run as containers
kindLatest stableDisposable local cluster
yqLatest stable, v4 syntaxAssertions in the render tests
Git and GitHub accountAny recent versionOnly needed for the CI step

Check what you already have before installing anything:

docker version --format '{{.Server.Version}}'
kind version
kubectl version --client
yq --version
helm version --short 2>/dev/null || echo "no helm on PATH yet"

If you have never built a local cluster, our Kubernetes cluster tutorial covers the basics in 13 steps, and everything here works on a cluster you already own as long as you can create a throwaway namespace.

One more note on scope. This guide assumes charts that use apiVersion: v2 in Chart.yaml, which is what Helm 3 produced and what Helm 4 still reads. You do not need to rewrite metadata to follow along.

What Helm 4 Changes for Chart Authors and Pipeline Owners

Helm 4 is deliberately conservative where charts are concerned. In an interview Intel published while Helm 4 was still in development, maintainer Matt Butcher said that “we have 70% to 80% of Kubernetes users touching Helm at one point or another.” He also described the constraint that follows from that reach: “That means we can’t introduce change that’s going to rock the boat so much that countless thousands and thousands of developer hours are spent rewriting charts, rebuilding the CICD pipelines, or rewriting the plugins to all the platforms we use that integrate with Helm.”

That philosophy explains the shape of the migration. Your templates, values files and Chart.yaml mostly carry over. The friction lives around the edges: command-line flags, wait behavior, how Helm applies manifests to the API server, and how plugins load. The table below summarizes the changes you will actually touch, based on the Helm 4 release documentation. Verify flag names on your own build with helm upgrade --help, since point releases can adjust deprecation windows.

AreaHelm 3 behaviorHelm 4 behaviorYour action
Rollback on failure–atomic–rollback-on-failure (atomic is deprecated)Rename flag in CI and Makefiles
Replace resources–force–force-replaceRename flag, and retest
Waiting for readinessPolls a fixed set of resource kindsStatus watcher based on kstatus, with selectable –wait strategiesExpect stricter, more accurate waits
Applying manifestsClient-side three-way mergeServer-side apply available, chosen automatically by defaultCheck field ownership on existing releases
Post-renderersExecutable pathPlugin namePackage your post-renderer as a plugin
PluginsShell-script style plugins onlyNew plugin system with WebAssembly support, signature verification on installRe-test every plugin you rely on
OCISupportedSupported, including install by digestPin by digest in production

Two items deserve a longer look. First, the wait logic. Helm 3’s --wait flag checked a hard-coded list of workload kinds. Helm 4 uses a status watcher, so a resource that never reports a ready condition can now hold up your upgrade until the timeout. That is more accurate, but it surfaces problems your old pipeline silently ignored. Second, server-side apply. Instead of computing a patch on your laptop, Helm 4 can hand the full object to the API server and let it track which field belongs to which manager. That shrinks a class of last-applied-configuration drift bugs, though it introduces field-ownership conflicts if another tool also edits the same object.

Cluster upgrades sit right beside chart upgrades in most roadmaps. If you are about to move to the newest Kubernetes minor too, read our Kubernetes 1.37 upgrade guide first, then decide whether to change the cluster and the Helm binary in the same maintenance window. Our advice is to split them. Change one variable at a time and you will always know which change broke a release.

Steps 1-3: Audit Your Estate, Install Helm 4 Beside Helm 3, Build the Lab

Step 1: Inventory releases, plugins and pipeline scripts

Migrations fail on the things nobody wrote down. Before you touch a binary, list every place Helm runs. That means your laptop, CI runners, Argo CD or Flux controllers, Terraform helm_release resources, and any container image that bundles a Helm CLI. Then list what those callers do with it.

Create the project directory and drop in this preflight script. It reports the Helm version, every release in the cluster with its chart, installed plugins, and every pipeline line that uses a flag Helm 4 renamed.

mkdir -p ~/helm4-lab/scripts && cd ~/helm4-lab
git init -q

cat > scripts/preflight.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

echo "== Helm binary =="
helm version --short || true

echo "== Releases in all namespaces =="
helm list -A -o table || true

echo "== Installed plugins =="
helm plugin list || true

echo "== Pipeline lines using flags that changed in Helm 4 =="
grep -rnE -- '--(atomic|force|post-renderer)([ =]|$)' \
  .github ci scripts Makefile Jenkinsfile .gitlab-ci.yml 2>/dev/null || echo "none found"

echo "== Hard-coded Helm versions =="
grep -rnE 'helm[-_ ]?(version|v)?[=: ]*v?3\.[0-9]+' \
  .github ci scripts Makefile Dockerfile* 2>/dev/null || echo "none found"
EOF
chmod +x scripts/preflight.sh
./scripts/preflight.sh

On a fresh machine the output is short. On a real estate it is the migration to-do list.

== Helm binary ==
no helm on PATH yet
== Releases in all namespaces ==
== Installed plugins ==
== Pipeline lines using flags that changed in Helm 4 ==
none found
== Hard-coded Helm versions ==
none found

Save the real output of your production audit somewhere durable. You will compare against it in Step 13.

Step 2: Install Helm 4 and Helm 3 as separate binaries

The safest migration technique is to stop having one binary called helm. Install both versions under explicit names, helm3 and helm4, and make every command say which one it means. Download from the official get.helm.sh host and verify the checksum. Adjust the architecture suffix if you are on ARM or macOS.

mkdir -p ~/helm4-lab/bin && cd ~/helm4-lab/bin
export HELM4_VERSION=v4.3.1
export HELM3_VERSION=v3.22.1
export OS=linux ARCH=amd64   # use darwin and arm64 on Apple silicon

for spec in "helm4:${HELM4_VERSION}" "helm3:${HELM3_VERSION}"; do
  name="${spec%%:*}"; ver="${spec##*:}"
  tarball="helm-${ver}-${OS}-${ARCH}.tar.gz"
  curl -fsSLO "https://get.helm.sh/${tarball}"
  curl -fsSLO "https://get.helm.sh/${tarball}.sha256sum"
  sha256sum -c "${tarball}.sha256sum"
  tar -xzf "${tarball}"
  mv "${OS}-${ARCH}/helm" "./${name}"
  rm -rf "${OS}-${ARCH}" "${tarball}" "${tarball}.sha256sum"
done

export PATH="$HOME/helm4-lab/bin:$PATH"
helm3 version --short
helm4 version --short
helm-v4.3.1-linux-amd64.tar.gz: OK
helm-v3.22.1-linux-amd64.tar.gz: OK
v3.22.1+g4f2a9c1
v4.3.1+g8b61d0e

Your commit hashes will differ, and that is fine. What matters is that the two lines show different major versions. If a newer 4.x patch exists when you read this, bump HELM4_VERSION and keep going. The project’s own documentation and release page are the source of truth for the newest tag.

Step 3: Create the kind cluster and a local OCI registry

Now build the lab. Run a local registry container first, then a kind cluster. The registry listens on port 5000 and will receive the packaged chart in Step 11. Helm talks to it from your host, so the cluster does not need to reach it.

docker run -d --restart=always -p 127.0.0.1:5000:5000 --name helm-registry registry:2

kind create cluster --name helm-lab
kubectl cluster-info --context kind-helm-lab
kubectl get nodes -o wide
kubectl version
Creating cluster "helm-lab" ...
 ✓ Ensuring node image
 ✓ Preparing nodes
 ✓ Writing configuration
 ✓ Starting control-plane
 ✓ Installing CNI
 ✓ Installing StorageClass
Set kubectl context to "kind-helm-lab"

NAME                     STATUS   ROLES           AGE   VERSION
helm-lab-control-plane   Ready    control-plane   42s   v1.37.0

If your kind release does not know Kubernetes 1.37 yet, it will fall back to the newest minor it ships. That is acceptable for this lab. To pin a version explicitly, pass --image kindest/node:TAG with a tag from the kind release notes. Do not guess a tag.

Steps 4-5: Build the Legacy Chart and Install a Helm 3 Baseline

Step 4: Scaffold the orders-api chart

The lab chart packages a tiny HTTP service called orders-api. It uses the public podinfo image because that image ships health endpoints, which makes readiness behavior easy to see. The chart has a Deployment, a Service, an optional PodDisruptionBudget, and a test pod. That mix is small, yet it exercises everything that tends to break in a Helm 4 migration: waits, hooks, and optional templates.

Real-world charts follow the same pattern at a larger scale. Game-server frameworks such as the one we deploy in our Agones on Kubernetes walkthrough install through Helm, so a migration problem in a toy chart usually predicts a problem in theirs. Start by generating the skeleton and clearing out the sample templates.

cd ~/helm4-lab
helm3 create orders-api
rm -rf orders-api/templates/* orders-api/charts
ls orders-api

Now write the chart metadata. Notice the kubeVersion constraint ends in a dash and zero. That suffix lets pre-release suffixes such as the ones managed clouds append to their version strings pass the check.

# orders-api/Chart.yaml
apiVersion: v2
name: orders-api
description: Demo orders service for the Helm 3 to Helm 4 migration lab
type: application
version: 1.0.0
appVersion: "6.7.1"
kubeVersion: ">=1.30.0-0"
# orders-api/values.yaml
replicaCount: 2

image:
  repository: ghcr.io/stefanprodan/podinfo
  tag: ""            # empty means "use appVersion"
  pullPolicy: IfNotPresent

message: "hello from orders-api"

service:
  type: ClusterIP
  port: 80

resources:
  requests:
    cpu: 25m
    memory: 32Mi
  limits:
    memory: 64Mi

podDisruptionBudget:
  enabled: true
  minAvailable: 1
{{/* orders-api/templates/_helpers.tpl */}}
{{- define "orders-api.name" -}}
{{- .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "orders-api.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

{{- define "orders-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "orders-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

{{- define "orders-api.labels" -}}
{{ include "orders-api.selectorLabels" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{- end -}}
# orders-api/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "orders-api.fullname" . }}
  labels:
    {{- include "orders-api.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "orders-api.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "orders-api.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 9898
          env:
            - name: PODINFO_UI_MESSAGE
              value: {{ .Values.message | quote }}
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
            periodSeconds: 3
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
# orders-api/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ include "orders-api.fullname" . }}
  labels:
    {{- include "orders-api.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: http
  selector:
    {{- include "orders-api.selectorLabels" . | nindent 4 }}

# orders-api/templates/pdb.yaml
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "orders-api.fullname" . }}
  labels:
    {{- include "orders-api.labels" . | nindent 4 }}
spec:
  minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
  selector:
    matchLabels:
      {{- include "orders-api.selectorLabels" . | nindent 6 }}
{{- end }}

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

The three files above sit in one block for readability. Each comment line names the file it belongs in, so split them when you save. A single mistake here is easy to make, so run a quick sanity check with Helm 3 before moving on.

helm3 lint orders-api --strict
==> Linting orders-api
[INFO] Chart.yaml: icon is recommended

1 chart(s) linted, 0 chart(s) failed

Step 5: Install the baseline release with Helm 3

You need a release that Helm 3 created, because that is the situation production is in. Install it the way many pipelines do, with the old atomic flag and a wait.

helm3 upgrade --install orders ./orders-api \
  --namespace shop --create-namespace \
  --atomic --wait --timeout 3m

helm3 list -n shop
kubectl get deploy,svc,pdb -n shop
kubectl get secret -n shop -l owner=helm
Release "orders" does not exist. Installing it now.
NAME: orders
LAST DEPLOYED: Thu Oct 22 22:41:07 2026
NAMESPACE: shop
STATUS: deployed
REVISION: 1

NAME     NAMESPACE  REVISION  UPDATED                   STATUS    CHART             APP VERSION
orders   shop       1         2026-10-22 22:41:07 UTC   deployed  orders-api-1.0.0  6.7.1

NAME                         TYPE                 DATA   AGE
sh.helm.release.v1.orders.v1 helm.sh/release.v1   1      19s

Look at the last line. Helm stores each revision as a Secret named after the release and revision number. Helm 4 reads that same storage format, which is why a release created by Helm 3 can be upgraded by Helm 4 without a conversion step. You will prove that in Step 7.

Steps 6-7: Prove Rendering Parity, Then Let Helm 4 Adopt the Release

Step 6: Lint and render with both versions, then diff the output

This is the single most valuable check in the whole migration. If the manifests Helm 4 renders match what Helm 3 renders, the template layer is safe, and every remaining risk sits in apply behavior, flags or plugins. If they differ, you have found the real work early and for free.

cd ~/helm4-lab
helm4 lint orders-api --strict

helm3 template orders ./orders-api -n shop --kube-version 1.37.0 > /tmp/render-h3.yaml
helm4 template orders ./orders-api -n shop --kube-version 1.37.0 > /tmp/render-h4.yaml

wc -l /tmp/render-h3.yaml /tmp/render-h4.yaml
diff -u /tmp/render-h3.yaml /tmp/render-h4.yaml && echo "IDENTICAL"
==> Linting orders-api
[INFO] Chart.yaml: icon is recommended

1 chart(s) linted, 0 chart(s) failed
  94 /tmp/render-h3.yaml
  94 /tmp/render-h4.yaml
 188 total
IDENTICAL

Run the same pair against every chart you own, not just the lab one. Script it with a loop over your chart directories and a values file per environment. A harmless difference in a header comment or key ordering may appear on some builds. Anything that touches a spec field, an annotation, or a label selector counts as a real finding, so stop and read it.

Pay special attention to selector labels. Changing matchLabels on an existing Deployment is not allowed by Kubernetes, so a rendering difference there would turn a routine upgrade into a failed one.

Step 7: Upgrade the Helm 3 release with Helm 4

Now the adoption test. Release metadata lives in the cluster, so Helm 4 can pick up where Helm 3 stopped. Change one harmless value to force a real revision and watch what happens.

helm4 upgrade orders ./orders-api -n shop \
  --set replicaCount=3 \
  --wait --timeout 3m

helm4 history orders -n shop
kubectl get deploy -n shop
kubectl get deploy orders-orders-api -n shop -o yaml --show-managed-fields \
  | yq '.metadata.managedFields[] | .manager + " / " + .operation'
Release "orders" has been upgraded. Happy Helming!
NAME: orders
STATUS: deployed
REVISION: 2

REVISION  UPDATED                   STATUS      CHART             APP VERSION  DESCRIPTION
1         Thu Oct 22 22:41:07 2026  superseded  orders-api-1.0.0  6.7.1        Install complete
2         Thu Oct 22 22:49:31 2026  deployed    orders-api-1.0.0  6.7.1        Upgrade complete

NAME                READY   UP-TO-DATE   AVAILABLE   AGE
orders-orders-api   3/3     3            3           8m

The history shows revision 1 created by Helm 3 and revision 2 by Helm 4, in one unbroken lineage. The managed-fields query is worth running too. It lists the field managers that wrote to the Deployment, and the names tell you whether Helm 4 applied its change client-side or through server-side apply. Exact manager names depend on the build and the apply mode, so read the output rather than expecting a specific string.

If you manage production releases with a controller such as Argo CD or Flux, remember that those tools embed their own Helm library. Their upgrade schedule is separate from yours. GitLab’s cloud-native operator, for instance, has a merge request that bumps its Helm v4 dependency to v4.3.1, which is a good sign that large downstream projects are already tracking the 4.x line. Check each controller’s release notes for which Helm major it bundles.

Steps 8-9: Translate Deprecated Flags, Add a Values Schema and Render Tests

Step 8: Rewrite renamed flags in scripts and Makefiles

Helm 4 renamed a few long-standing flags to say what they do. The old names still work in many cases with a warning, but a deprecation warning is a future error. Update your callers now while nothing is on fire.

Helm 3 usageHelm 4 replacementNotes
helm upgrade –atomichelm upgrade –rollback-on-failureSame intent, clearer name. Implies waiting.
helm upgrade –forcehelm upgrade –force-replaceDeletes and recreates objects. Use sparingly.
helm install –post-renderer ./script.shhelm install –post-renderer PLUGIN_NAMEWrap the script in a plugin first.
helm install –waithelm install –wait (watcher by default)Pick a strategy explicitly if you need the old behavior.
helm plugin install URLhelm plugin install URL (verifies signatures)Unsigned plugins need an explicit opt-out.

Run the substitutions on a branch and read the diff. A blind sed across a monorepo will eventually rename something it should not, so keep the pattern tight and anchored on the flag boundary.

git checkout -b chore/helm4-flags 2>/dev/null || true

# Preview first. Nothing is written yet.
grep -rnE -- '--(atomic|force)([ =]|$)' .github ci scripts Makefile 2>/dev/null

# Apply to the files you reviewed
sed -i -E 's/--atomic([ =]|$)/--rollback-on-failure\1/g; s/--force([ =]|$)/--force-replace\1/g' \
  Makefile scripts/*.sh .github/workflows/*.yml 2>/dev/null || true

git diff --stat

Then test the renamed flag against the lab cluster. The command below should still deploy cleanly:

helm4 upgrade --install orders ./orders-api -n shop \
  --rollback-on-failure --wait --timeout 3m

One flag has no one-to-one replacement. If you have used --wait with a long timeout as a crutch for slow-starting resources, decide now whether the new watcher behavior suits you. Run helm4 upgrade --help and read the wait section for the strategies your build supports. Choose one explicitly in CI so a later default change cannot surprise you.

Step 9: Add a values schema and a render test suite

A values.schema.json file makes Helm reject bad input before it reaches the cluster. It works in Helm 3 and Helm 4 alike, so adding it is a pure win and a cheap way to catch typos in per-environment values files. Setting additionalProperties to false at the top level turns a misspelled key into an error rather than a silent no-op.

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": false,
  "required": ["replicaCount", "image", "service"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 1 },
    "message": { "type": "string" },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string", "minLength": 1 },
        "tag": { "type": "string" },
        "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
      }
    },
    "service": {
      "type": "object",
      "properties": {
        "type": { "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
        "port": { "type": "integer", "minimum": 1, "maximum": 65535 }
      }
    },
    "resources": { "type": "object" },
    "podDisruptionBudget": {
      "type": "object",
      "properties": {
        "enabled": { "type": "boolean" },
        "minAvailable": { "type": ["integer", "string"] }
      }
    }
  }
}

Save that as orders-api/values.schema.json. Next, write a render test script. It uses only helm and yq, so there is no third-party Helm plugin to qualify against a new major version. That choice is deliberate. Plugins are the part of a Helm 3 estate most likely to lag behind, and a test suite that depends on one adds risk to the migration it is supposed to protect.

mkdir -p tests
cat > tests/render-test.sh <<'EOF'
#!/usr/bin/env bash
set -uo pipefail
HELM="${HELM:-helm4}"
CHART="${CHART:-orders-api}"
failures=0

pass() { echo "PASS: $1"; }
fail() { echo "FAIL: $1"; failures=$((failures + 1)); }

replicas=$($HELM template t "$CHART" | yq 'select(.kind == "Deployment") | .spec.replicas')
if [ "$replicas" = "2" ]; then pass "default replicas is 2"; else fail "default replicas was $replicas"; fi

pdb=$($HELM template t "$CHART" | yq 'select(.kind == "PodDisruptionBudget") | .metadata.name')
if [ -n "$pdb" ]; then pass "PDB renders by default"; else fail "PDB missing"; fi

nopdb=$($HELM template t "$CHART" --set podDisruptionBudget.enabled=false \
  | yq 'select(.kind == "PodDisruptionBudget") | .kind')
if [ -z "$nopdb" ]; then pass "PDB removed when disabled"; else fail "PDB still rendered"; fi

image=$($HELM template t "$CHART" | yq 'select(.kind == "Deployment") | .spec.template.spec.containers[0].image')
case "$image" in
  *:6.7.1) pass "image tag falls back to appVersion" ;;
  *) fail "unexpected image $image" ;;
esac

if $HELM template t "$CHART" --set replicaCount=lots >/dev/null 2>&1; then
  fail "schema accepted a string for replicaCount"
else
  pass "schema rejects non-integer replicaCount"
fi

if $HELM template t "$CHART" --set replicaCnt=3 >/dev/null 2>&1; then
  fail "schema accepted a misspelled key"
else
  pass "schema rejects misspelled keys"
fi

echo "failures: $failures"
exit "$failures"
EOF
chmod +x tests/render-test.sh

HELM=helm3 ./tests/render-test.sh
HELM=helm4 ./tests/render-test.sh
PASS: default replicas is 2
PASS: PDB renders by default
PASS: PDB removed when disabled
PASS: image tag falls back to appVersion
PASS: schema rejects non-integer replicaCount
PASS: schema rejects misspelled keys
failures: 0

Running the suite against both binaries is the point. A test that passes on Helm 3 and fails on Helm 4 pinpoints exactly which behavior changed, with no cluster required. Add one test per bug your team has ever shipped and the suite will start to pay for itself within a quarter.

One last schema warning. Do not put credentials into values.yaml just because the schema makes it easy to validate them. Keep secrets in an external store and reference them by name. Our guide to securing Kubernetes secrets shows several ways to do that without leaking them into release history, which Helm stores in the cluster in plain, base64-wrapped form.

Steps 10-11: Validate Against the Live API, Then Publish to an OCI Registry

Step 10: Run a server-side dry run and the chart test hook

Rendering proves your templates produce YAML. It does not prove the cluster will accept that YAML. A server-side dry run sends the manifests to the real API server, which runs admission, defaulting and schema validation without persisting anything. It catches removed API versions, bad field names and policy violations that helm template cannot see.

helm4 upgrade orders ./orders-api -n shop --dry-run=server \
  --set replicaCount=4 | head -20

helm4 test orders -n shop --logs
NAME: orders
LAST DEPLOYED: Thu Oct 22 23:02:44 2026
NAMESPACE: shop
STATUS: pending-upgrade
REVISION: 4

NAME:           orders-orders-api-test
LAST DEPLOYED:  Thu Oct 22 23:03:10 2026
NAMESPACE:      shop
STATUS:         deployed
REVISION:       3
TEST SUITE:     Last Started: Thu Oct 22 23:03:10 2026
                Last Completed: Thu Oct 22 23:03:13 2026
                Phase:          Succeeded
POD LOGS: orders-orders-api-test
{"ok":true}

The dry run reports pending-upgrade because it simulates the revision it would create. The test hook then starts a short-lived pod that calls the Service’s health endpoint from inside the cluster. A passing test means templates, labels, selectors and networking all line up end to end.

If your real charts render Ingress or Gateway objects, repeat this step against a cluster that runs the matching controller. Controller choice changed this year, and our guide to Kubernetes ingress setup after NGINX end of life explains what to install before you test those templates. A server-side dry run fails loudly when the API group for an Ingress class does not exist.

Step 11: Package the chart and push it to OCI by tag and by digest

Classic Helm repositories, built from an index.yaml on a web server, still work. But the ecosystem keeps moving toward OCI registries, and recent activity shows maintainers publishing both ways. Reportedly, the Kubernetes Metrics Server project has begun publishing its Helm chart as an OCI artifact. Artifact Hub, which helm search hub queries, continues to index charts from both kinds of source, so discovery does not force a choice.

Package and push to the local registry. The --plain-http flag is needed only because this lab registry has no TLS. Never use it against a shared registry.

helm4 package ./orders-api --destination ./dist
helm4 push ./dist/orders-api-1.0.0.tgz oci://localhost:5000/charts --plain-http

helm4 show chart oci://localhost:5000/charts/orders-api \
  --version 1.0.0 --plain-http | head -8
Successfully packaged chart and saved it to: ./dist/orders-api-1.0.0.tgz
Pushed: localhost:5000/charts/orders-api:1.0.0
Digest: sha256:3c1f0b7e5d...e91a
apiVersion: v2
appVersion: 6.7.1
description: Demo orders service for the Helm 3 to Helm 4 migration lab
kubeVersion: '>=1.30.0-0'
name: orders-api
type: application
version: 1.0.0

A tag such as 1.0.0 is a mutable pointer. Anyone with push rights can overwrite it. A digest is a content hash and cannot change without becoming a different string. Helm 4 can install by digest, which gives you the same guarantee container images already enjoy. Copy the digest from your own push output into this command:

DIGEST=sha256:PASTE_THE_DIGEST_FROM_YOUR_PUSH_OUTPUT

helm4 upgrade --install orders-oci \
  oci://localhost:5000/charts/orders-api@${DIGEST} \
  -n shop-oci --create-namespace --plain-http \
  --rollback-on-failure --wait --timeout 3m

helm4 list -n shop-oci

For a real registry such as GitHub Container Registry, authenticate first with helm registry login. Pass the token on standard input so it never lands in your shell history:

echo "$GHCR_TOKEN" | helm4 registry login ghcr.io -u YOUR_GITHUB_USER --password-stdin
helm4 push ./dist/orders-api-1.0.0.tgz oci://ghcr.io/YOUR_GITHUB_USER/charts

In GitOps setups, pin the digest in the manifest that your controller reads. A tag can move underneath you after review. A digest cannot.

Steps 12-13: Rehearse a Failed Upgrade, Then Lock It Into CI

Step 12: Break an upgrade on purpose and watch the rollback

Nobody trusts a safety net they have never fallen into. Point the release at an image tag that does not exist, set a short timeout, and let the renamed flag do its job. The Deployment will sit in ImagePullBackOff, the wait will time out, and Helm should roll back to the last good revision.

helm4 upgrade orders ./orders-api -n shop \
  --set image.tag=does-not-exist \
  --rollback-on-failure --wait --timeout 90s

echo "exit code: $?"
helm4 history orders -n shop
kubectl get pods -n shop
Error: UPGRADE FAILED: release orders failed, and has been rolled back due to rollback-on-failure being set: context deadline exceeded
exit code: 1

REVISION  UPDATED                   STATUS      CHART             DESCRIPTION
3         Thu Oct 22 22:58:02 2026  superseded  orders-api-1.0.0  Upgrade complete
4         Thu Oct 22 23:06:15 2026  failed      orders-api-1.0.0  Upgrade "orders" failed: context deadline exceeded
5         Thu Oct 22 23:07:46 2026  deployed    orders-api-1.0.0  Rollback to 3

NAME                                 READY   STATUS    RESTARTS   AGE
orders-orders-api-6d9c7b8f4-x2k9p    1/1     Running   0          9m
orders-orders-api-6d9c7b8f4-qv7lt    1/1     Running   0          9m

The output above is abridged and the exact error wording varies by build, so look for the shape rather than the string. Three things should be true. The command exits non-zero so CI marks the job red. History shows a failed revision followed by a rollback revision. And the original pods are still serving traffic. The PodDisruptionBudget plus a rolling update kept at least one replica ready for the whole attempt.

Now try the manual route, which you will need when automatic rollback was not enabled:

helm4 upgrade orders ./orders-api -n shop --set image.tag=does-not-exist --timeout 60s || true
helm4 history orders -n shop
helm4 rollback orders 3 -n shop --wait --timeout 2m
helm4 get values orders -n shop

Rollback re-applies a stored revision as a brand new revision, so history only ever grows. Remember that rollback restores Kubernetes objects. It does not undo database migrations, deleted PersistentVolumeClaims or anything a hook did outside the chart. If you ship progressive delivery on top of Helm, our walkthrough on setting up Argo Rollouts adds traffic-aware promotion that catches bad releases earlier than a readiness timeout.

Step 13: Pin Helm 4 in CI and publish on every tag

The final step makes the migration permanent. A CI workflow that installs “whatever helm is current” will drift, so pin an exact version and add a guard that fails if the major version is wrong. The workflow below lints, runs the render tests, installs into a throwaway kind cluster, runs the chart test hook, and on a version tag pushes the chart to GitHub Container Registry.

# .github/workflows/chart.yml
name: chart

on:
  pull_request:
  push:
    tags: ["v*"]

permissions:
  contents: read
  packages: write

env:
  HELM_VERSION: v4.3.1

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: azure/setup-helm@v4
        with:
          version: ${{ env.HELM_VERSION }}

      - name: Guard the Helm major version
        run: |
          helm version --short
          helm version --short | grep -q '^v4\.' || { echo "Helm 4 required"; exit 1; }

      - name: Lint and render tests
        run: |
          helm lint orders-api --strict
          HELM=helm ./tests/render-test.sh

      - uses: helm/kind-action@v1
        with:
          cluster_name: ci

      - name: Install and test on kind
        run: |
          helm upgrade --install orders ./orders-api -n shop --create-namespace \
            --rollback-on-failure --wait --timeout 3m
          helm test orders -n shop --logs

  publish:
    needs: verify
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: azure/setup-helm@v4
        with:
          version: ${{ env.HELM_VERSION }}
      - name: Log in to GHCR
        run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${{ github.actor }}" --password-stdin
      - name: Package and push
        run: |
          OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
          helm package orders-api --destination dist
          helm push dist/orders-api-*.tgz "oci://ghcr.io/${OWNER}/charts"

Two details matter. The guard step turns a silent version regression into a red build, so nobody can quietly swap the runner image back to Helm 3. And the owner name is lowercased before the push, because OCI repository names must be lowercase and GitHub organization names often are not.

Finish by re-running the Step 1 preflight script against your real repositories. The “none found” lines mean the migration is done. Anything else is a ticket, and the Helm 3 end date is February 10, 2027.

Common Pitfalls That Derail a Helm 4 Migration

These are the mistakes that cost teams the most time, in rough order of how often they bite.

  1. Leaving two binaries both named helm. A runner image that carries Helm 3 in /usr/local/bin and Helm 4 in a tool cache will pick whichever comes first on PATH. Your job logs say one thing and your cluster state says another. Print helm version --short at the top of every pipeline and fail on the wrong major, as the workflow in Step 13 does.
  2. Treating deprecation warnings as noise. The old –atomic and –force flags may still run today. That grace period exists so you can fix scripts calmly, not so you can ignore them for a year. Grep for them monthly until the preflight script comes back clean.
  3. Assuming plugins just work. Plugins are the most common source of migration surprises, because Helm 4 reworked how they load and verifies signatures on install. Inventory every plugin in Step 1, check each project’s release notes for Helm 4 support, and replace any that went quiet.
  4. Skipping the render diff. Teams run an upgrade in staging, see green, and ship. The render diff in Step 6 catches template-level surprises in seconds, with no cluster at all. Run it for every chart and every values file, including the awkward per-region overrides nobody remembers.
  5. Being surprised by stricter waits. The status watcher waits for resources that Helm 3 never checked. A custom resource that never publishes a ready condition, or a Job that finishes slowly, now stretches an upgrade to its timeout. Fix the resource’s status reporting, or choose a wait strategy on purpose.
  6. Mixing apply owners. If kubectl, a GitOps controller and Helm all write the same object, server-side apply will report field conflicts that client-side merging used to hide. Decide which tool owns each field, and remove the others from the loop.
  7. Moving the cluster and the client in one window. A Kubernetes minor upgrade and a Helm major upgrade in the same change make failures hard to attribute. Reportedly, Kubernetes 1.34 reaches end of life on October 27, 2026, so many teams face both deadlines at once. Sequence them anyway, one per window.
  8. Unpinned installers in CI. Scripts that download the latest Helm release will pull the next version the day it ships, straight into a release branch. Pin an exact version and bump it deliberately.

Troubleshooting: 10 Errors You Will Probably Hit

Each item below lists the symptom, the usual cause, and the fix. Error wording shifts slightly between releases, so match on the shape of the message.

  1. Kubernetes cluster unreachable. The install fails before any template work. Your kubeconfig points at the wrong context or the kind container stopped. Run kubectl config current-context, then kind get clusters, and switch with kubectl config use-context kind-helm-lab.
  2. Another operation is in progress. A previous upgrade was killed mid-flight and left the release in a pending state. List the history, then roll back to the last deployed revision with helm4 rollback orders N -n shop. As a last resort, delete only the pending revision’s release Secret after confirming its status.
  3. Resource exists and cannot be imported into the current release. Helm refuses to adopt an object it does not own. Add the ownership metadata to the object, namely the label app.kubernetes.io/managed-by=Helm and the annotations meta.helm.sh/release-name and meta.helm.sh/release-namespace, then retry.
  4. Server-side apply field conflict. Another manager owns a field you are changing. Find the owner with the managed-fields query from Step 7. Either remove that field from the other tool’s manifests, or, when you are certain Helm should win, check helm4 upgrade --help for the force-conflicts option and retry.
  5. Values do not meet the specifications of the schema. Your schema is doing its job. Read the path in the message, fix the values file, and rerun. If the schema itself is wrong, loosen it in a reviewed commit rather than deleting it.
  6. Plugin install fails verification. Helm 4 checks plugin signatures. For a plugin you have reviewed and trust, check the install help for the flag that disables verification, and record that decision in your runbook. Prefer plugins that publish signatures.
  7. Server gave HTTP response to HTTPS client. You pushed to a plain-HTTP registry without telling Helm. Add --plain-http for the local lab registry only. For anything shared, put TLS in front of the registry instead.
  8. Context deadline exceeded during upgrade. Either the pods never became ready or the new wait logic is watching a resource that never reports status. Run kubectl describe pod -n shop and kubectl get events -n shop --sort-by=.lastTimestamp to see why. Raise the timeout only after you understand the cause.
  9. Chart requires kubeVersion that managed clusters fail. Cloud providers append suffixes to version strings, and a strict constraint rejects them. End the constraint with a dash and zero, as the lab chart does.
  10. Helm test pod fails or lingers. A previous test pod with the same name still exists. The hook-delete-policy annotation in the lab chart removes old pods before creating new ones. If your pod hangs, run kubectl logs on it and check that the Service name in the test command matches the rendered name.

A final habit saves hours. When anything fails, capture three artifacts before you retry: helm4 get all orders -n shop, the relevant kubectl get events output, and the exact command line that failed. Retrying first and asking questions later destroys the evidence.

Advanced Tips for Production Pipelines

Review diffs before they apply. The community helm-diff plugin shows what an upgrade would change against the live release, and it earns its keep in pull request comments. Qualify it against Helm 4 on your own cluster before you depend on it, and install it from a pinned release. The same caution applies to every plugin in your toolbox.

Manage many releases declaratively. If you run dozens of releases per environment, a tool like Helmfile keeps the whole set in one reviewed file. Whichever tool you choose, point it at the Helm 4 binary explicitly and run the render parity check from Step 6 across all environments in CI, not only the one you edit most.

Sign what you publish. Helm supports provenance files for packaged charts, and registries can store signatures next to the artifact. Signing means a consumer can verify the chart came from your pipeline before installing it. Combine that with digest pinning from Step 11 and a compromised tag stops being an incident.

Catch removed APIs early. Upgrading a cluster can remove API versions that old release records still reference. Use helm template --kube-version against the target version for every chart before the cluster moves, and consider the community mapkubeapis plugin for releases that already contain deprecated objects. Our Karpenter on EKS tutorial is a good example of a chart that you install with Helm and that depends on API versions staying in step with the cluster.

Keep values lean. Autoscaling, resource requests and disruption budgets belong in values with sane defaults, not hard-coded in templates. If you scale workloads with HPA or KEDA, the pattern in our guide to autoscaling with Kubernetes HPA and KEDA maps directly onto optional template blocks like the PDB in this lab.

Track the platform, not just the binary. Helm 4’s own roadmap continues, and the project reportedly schedules Helm 4.4.0 for January 13, 2027. Subscribe to the release feed, test each minor in a canary pipeline first, and browse our broader cloud computing coverage for the cluster-side changes that tend to land alongside.

Complete Working Project: Layout and Makefile

Here is the finished directory. Every file appeared in a step above, so you can assemble it as you go or copy it all at once.

helm4-lab/
├── .github/workflows/chart.yml
├── Makefile
├── bin/
│   ├── helm3
│   └── helm4
├── orders-api/
│   ├── Chart.yaml
│   ├── values.yaml
│   ├── values.schema.json
│   └── templates/
│       ├── _helpers.tpl
│       ├── deployment.yaml
│       ├── service.yaml
│       ├── pdb.yaml
│       └── tests/test-connection.yaml
├── scripts/preflight.sh
└── tests/render-test.sh

The Makefile ties the checks together so a teammate can run one command. Recipe lines must start with a tab character.

HELM ?= helm4
CHART := orders-api
NS ?= shop
KUBE_VERSION ?= 1.37.0
REGISTRY ?= oci://localhost:5000/charts

.PHONY: preflight lint test parity dry-run deploy package push all clean

preflight:
	./scripts/preflight.sh

lint:
	$(HELM) lint $(CHART) --strict

test:
	HELM=$(HELM) ./tests/render-test.sh

parity:
	helm3 template orders $(CHART) -n $(NS) --kube-version $(KUBE_VERSION) > /tmp/render-h3.yaml
	$(HELM) template orders $(CHART) -n $(NS) --kube-version $(KUBE_VERSION) > /tmp/render-h4.yaml
	diff -u /tmp/render-h3.yaml /tmp/render-h4.yaml

dry-run:
	$(HELM) upgrade --install orders ./$(CHART) -n $(NS) --create-namespace --dry-run=server

deploy:
	$(HELM) upgrade --install orders ./$(CHART) -n $(NS) --create-namespace \
	  --rollback-on-failure --wait --timeout 3m
	$(HELM) test orders -n $(NS) --logs

package:
	$(HELM) package ./$(CHART) --destination ./dist

push: package
	$(HELM) push ./dist/$(CHART)-*.tgz $(REGISTRY) --plain-http

all: lint test parity dry-run deploy

clean:
	-$(HELM) uninstall orders -n $(NS)
	-kind delete cluster --name helm-lab
	-docker rm -f helm-registry
make all

==> Linting orders-api
1 chart(s) linted, 0 chart(s) failed
PASS: default replicas is 2
...
failures: 0
IDENTICAL
NAME: orders
STATUS: deployed
Phase:          Succeeded

When you finish, run make clean to remove the release, the kind cluster and the registry container. Then copy the pattern into a real chart repository, one chart at a time, starting with the least critical service you own.

FAQ: Helm Chart Migration to Helm 4

Do my Helm 3 charts work with Helm 4?

Most should. The maintainers say they expect existing Helm 3 charts, releases and workflows to be compatible with Helm 4, but they describe that as an expectation rather than a guarantee. Run the render diff from Step 6 on every chart to confirm.

When does Helm 3 stop getting security fixes?

Security fixes end on February 10, 2027, extended from the original November 2026 date. The final Helm 3 feature release shipped on September 9, 2026. After February 10, 2027, no Helm 3 releases of any kind will ship.

Can Helm 3 and Helm 4 manage the same release?

Technically yes, because both read the same release Secrets, as Step 7 shows. In practice, pick one tool per release and stay with it. Alternating clients invites apply-ownership confusion and makes audit trails harder to read.

Do I need to change apiVersion in Chart.yaml?

No. Charts with apiVersion: v2 keep working, and this tutorial never edits that field. Leave it alone unless the Helm documentation tells you otherwise for a feature you want.

Should I switch from classic repositories to OCI registries?

It is optional, and both are documented. OCI gives you digest pinning and reuses the registry you already run for container images. Many projects publish both ways, so you can adopt OCI for your own charts without giving up Artifact Hub for discovery.

What breaks most often during the migration?

Pipeline scripts and plugins, far more often than templates. Renamed flags, post-renderers that expect a path, and third-party plugins that have not caught up cause the majority of failures. The preflight script in Step 1 finds the flag problems before they run.

Which Kubernetes version should I test against?

Test against the version your clusters run today and the next one you plan to adopt. Kubernetes 1.37 is the current upstream minor, released August 26, 2026. Use the --kube-version flag on helm template to render for a target version, and a server-side dry run to confirm against a real API server.

Sofia LindstrÃķm

Sofia LindstrÃķm

Editor-in-Chief

Sofia LindstrÃķm is the Editor-in-Chief at Tech Insider, where she leads editorial strategy and oversees coverage across AI, cybersecurity, and enterprise technology. With over a decade in Swedish tech journalism, she previously served as technology editor at Dagens Industri and covered the Nordic startup ecosystem for Breakit. Sofia holds an MSc in Media Technology from KTH Royal Institute of Technology and is a frequent speaker at Web Summit and Slush. She is passionate about making complex technology accessible to business leaders.

View all articles