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.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
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.
| Milestone | Date | What it means for your pipeline |
|---|---|---|
| Helm v4.0.0 released | November 12, 2025 | First major Helm release in 6 years, announced at KubeCon + CloudNativeCon |
| Final Helm 3 feature release | September 9, 2026 | Last Helm 3 minor, limited to Kubernetes client-library updates |
| Helm 3 security fixes end | February 10, 2027 | No updates of any kind after this date, including CVE patches |
| Kubernetes 1.37 “Garhwal” released | August 26, 2026 | Current 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.
| Tool | Version used in this guide | Why it matters |
|---|---|---|
| Helm 4 | 4.x line, pinned to v4.3.1 in the commands | The migration target |
| Helm 3 | 3.x line, pinned to v3.22.1 in the commands | Baseline for comparison only |
| Kubernetes (kind node) | 1.37 or the newest your kind release supports | Matches what managed clouds are adopting |
| kubectl | Within one minor version of the cluster | Kubernetes version-skew policy |
| Docker Engine | Latest stable | kind nodes and the local registry run as containers |
| kind | Latest stable | Disposable local cluster |
| yq | Latest stable, v4 syntax | Assertions in the render tests |
| Git and GitHub account | Any recent version | Only 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.
| Area | Helm 3 behavior | Helm 4 behavior | Your action |
|---|---|---|---|
| Rollback on failure | –atomic | –rollback-on-failure (atomic is deprecated) | Rename flag in CI and Makefiles |
| Replace resources | –force | –force-replace | Rename flag, and retest |
| Waiting for readiness | Polls a fixed set of resource kinds | Status watcher based on kstatus, with selectable –wait strategies | Expect stricter, more accurate waits |
| Applying manifests | Client-side three-way merge | Server-side apply available, chosen automatically by default | Check field ownership on existing releases |
| Post-renderers | Executable path | Plugin name | Package your post-renderer as a plugin |
| Plugins | Shell-script style plugins only | New plugin system with WebAssembly support, signature verification on install | Re-test every plugin you rely on |
| OCI | Supported | Supported, including install by digest | Pin 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 usage | Helm 4 replacement | Notes |
|---|---|---|
| helm upgrade –atomic | helm upgrade –rollback-on-failure | Same intent, clearer name. Implies waiting. |
| helm upgrade –force | helm upgrade –force-replace | Deletes and recreates objects. Use sparingly. |
| helm install –post-renderer ./script.sh | helm install –post-renderer PLUGIN_NAME | Wrap the script in a plugin first. |
| helm install –wait | helm install –wait (watcher by default) | Pick a strategy explicitly if you need the old behavior. |
| helm plugin install URL | helm 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.
- 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 --shortat the top of every pipeline and fail on the wrong major, as the workflow in Step 13 does. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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, thenkind get clusters, and switch withkubectl config use-context kind-helm-lab. - 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. - 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=Helmand the annotationsmeta.helm.sh/release-nameandmeta.helm.sh/release-namespace, then retry. - 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 --helpfor the force-conflicts option and retry. - 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.
- 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.
- Server gave HTTP response to HTTPS client. You pushed to a plain-HTTP registry without telling Helm. Add
--plain-httpfor the local lab registry only. For anything shared, put TLS in front of the registry instead. - 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 shopandkubectl get events -n shop --sort-by=.lastTimestampto see why. Raise the timeout only after you understand the cause. - 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.
- 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 logson 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.





