Prerequisites and lab setup
Install Helm 4.3.0 and the supporting tools into the repository, isolate every Helm state directory, and bring up a minikube profile with operators only.
Every chapter after this one runs Helm against a cluster, so this chapter builds the lab once and makes it disposable. The new idea is isolation: Helm 4 lives inside the repository, its config, cache and plugins live beside it, and the cluster is a single minikube profile named helm4dev that holds operators and nothing else.
The code is in examples/01-lab-setup/. ./demo.sh offline checks the toolchain without touching a cluster; ./demo.sh builds the whole lab.
What you need on the host
The lab was run with minikube 1.38.1. It also needs kubectl, a container engine (Docker or Podman), curl, tar, sha256sum and python3. The minikube profile uses the docker driver with the containerd runtime, 12g of RAM and 8 CPUs. Everything else, including Helm, comes from scripts/install-tools.sh.
The service images are built on CPython 3.14.8, not 3.15. uv had no 3.15.0 build and aiokafka had no cp315 wheel when the images were first built, and CONTRIBUTING.md records the fallback. Nothing in the Helm chapters depends on the interpreter version; the change is one ARG PYTHON_VERSION line in services/Containerfile.
Why Helm lives in the repository
A global Helm 3 may already be on your PATH. By default both major versions read the same ~/.config/helm repository list and ~/.cache/helm cache on Linux, so a second Helm major version sharing them is an avoidable risk. The lab avoids the clash by never touching the global install. The Helm 4 binary goes into .tools/bin, and four environment variables redirect all of Helm’s state under .tools/helm/:
| Variable | Value | Holds |
|---|---|---|
HELM_CONFIG_HOME |
.tools/helm/config |
repositories.yaml, registry credentials |
HELM_CACHE_HOME |
.tools/helm/cache |
repository indexes, the content cache of pulled charts |
HELM_DATA_HOME |
.tools/helm/data |
local plugin data |
HELM_PLUGINS |
.tools/helm/plugins |
installed plugins (unittest, diff) |
Deleting .tools/ resets every part of the toolchain, and your other projects never see any of it. helm --help documents the HELM_*_HOME variables; the Helm 4 overview lists the new content-based cache that HELM_CACHE_HOME now also holds.
How the code works
scripts/env.sh
Every script and every demo starts with source scripts/env.sh. It does four jobs in order.
First it finds the repository root from its own path (using BASH_SOURCE or the zsh equivalent) and exports HFD_ROOT. Nothing in the lab hard-codes ~/Dev.
Second it prepends $HFD_ROOT/.tools/bin to PATH, so helm resolves to the project copy. It then exports the four Helm variables above, plus GNUPGHOME=$HFD_ROOT/.tools/gnupg (used for throwaway signing keys in chapter 21) and MINIKUBE_PROFILE=helm4dev.
Third it creates those directories, and links $HELM_DATA_HOME/plugins to $HELM_PLUGINS. The link exists because Helm 4 installs a plugin from a local directory under the data home, while the HELM_PLUGINS variable points at where it searches. One physical directory serves both.
Fourth it enforces the version. Unless HFD_SKIP_HELM_CHECK=1, it runs helm version --short and fails if the answer does not start with v4.. A reader whose shell still resolves to Helm 3 gets a message naming the binary that was found, instead of a chapter that fails ten steps later with a confusing flag error. install-tools.sh sets the skip variable for the one run that creates Helm, since no Helm 4 exists yet.
scripts/install-tools.sh
The versions are variables at the top, pinned on 2026-10-08: Helm 4.3.0, kubeconform 0.8.0, chart-testing 3.15.0, cosign 3.1.3, helmfile 1.8.1, helm-unittest 1.2.1 and helm-diff 3.15.15. Each can be overridden by environment variable.
Each download follows the same shape: fetch the release asset, fetch its checksum file, verify_sum compares SHA-256 and aborts on a mismatch, then install copies the binary into .tools/bin. A have_version check makes the script idempotent; --force reinstalls. Helm comes from https://get.helm.sh/helm-v4.3.0-linux-amd64.tar.gz together with its .sha256sum file. yamllint and yamale, which chart-testing calls, go in a Python virtual environment at .tools/venv and are linked into .tools/bin.
The plugins come last, because helm plugin install needs the new Helm. helm plugin install defaults to --verify=true, which checks a signature against $GNUPGHOME/pubring.kbx. The pinned upstream releases are installed without that signature check, so the script passes --verify=false when helm plugin install --help lists the flag, and helm plugin list shows their provenance as unknown. Chapter 22 returns to plugin provenance.
scripts/platform/bootstrap.sh
The bootstrap runs four tiers and gates each on the health of the one before:
setup-profile.shcreates or starts thehelm4devminikube profile with every host port published (--ports) and enables the registry addon. It refuses to act on any other profile name, checksfs.inotify.max_user_instances, and warns when other minikube profiles are running and competing for RAM.setup-postgres-operator.shrunshelm upgrade --install cnpg cnpg/cloudnative-pgintocnpg-system.setup-kafka-operator.shinstalls Strimzi intostrimziwithwatchAnyNamespace=true, so one operator serves everyhfd-NNnamespace.setup-lgtm.shinstalls Loki, Grafana, Tempo and Mimir intoobservability. Its Grafana only loads dashboard ConfigMaps from its own namespace; chapter 26 upgrades the shared Grafana withsidecar.dashboards.searchNamespace=ALLso the umbrella’s dashboard inhfd-26appears.
The bootstrap installs operators only: the machinery that understands a Cluster or a Kafka custom resource. It never creates a database or a broker, because from chapter 09 onward the charts you write own those resources, and that is what a chart is for. Every helper in lib.sh pins --context helm4dev explicitly, so the kubectl context you happen to have active never decides where a command lands.
scripts/build-images.sh
The script builds shipping-service:0.1.0 and notification-service:0.1.0 from one services/Containerfile, selecting the service with --build-arg SERVICE=<name>. With Docker or Podman it builds on the host and runs minikube -p helm4dev image load; with BUILD_ENGINE=minikube it builds inside the node. The charts default to image.repository: shipping-service and a tag taken from .Chart.AppVersion, so the same image serves every chapter and only charts and values change. build-images.sh push additionally pushes to the registry addon at 127.0.0.1:5000, a node port published when the profile was created. Push mode prefers Podman when it is installed, because a Docker daemon that runs in a VM (Docker Desktop) cannot reach a port published on the host’s 127.0.0.1; set BUILD_ENGINE=docker to force Docker, for example with Docker Engine on Linux.
Host access: published NodePorts
Services that the host must reach are NodePort Services with fixed ports, and setup-profile.sh publishes each of those ports when it creates the profile: minikube start --ports=127.0.0.1:30080:30080,... with the docker driver. Each port binds to 127.0.0.1 only. A bare 30080:30080 binds every interface (0.0.0.0), which would expose Grafana, the registry and the applications to the local network. The host port equals the NodePort, so http://127.0.0.1:30080 reaches shipping directly. There is no SSH tunnel, no kubectl port-forward and no minikube tunnel. Those are separate processes that drop their connection when idle or when a pod restarts, and the failure looks like an application bug. A published port is part of the node container and stays up as long as the cluster does.
The list lives in one place, the HFD_NODE_PORTS array in scripts/platform/lib.sh:
| Port | Owner |
|---|---|
| 5000 | Registry addon (build-images.sh push, chapters 20, 24 and 25) |
| 30080 | shipping-service (chapters 03 to 12, 15 to 17, 19 to 21, 24 to 26) |
| 30081 | notification-service (chapters 15 to 17, 24 to 26) |
| 30082 | Argo CD server, HTTP (chapter 25) |
| 30090 | Git-sourced Application shipping-git (chapter 25) |
| 30190, 30191 | Helm repository Application platform-repo: shipping and notification (chapter 25) |
| 30300 | Grafana (chapter 26) |
| 30443 | Argo CD server, HTTPS (chapter 25) |
Published ports are fixed when the profile is created. To add one, add it to HFD_NODE_PORTS and recreate the profile with scripts/platform/setup-profile.sh --replace --confirm=helm4dev, then rerun scripts/platform/bootstrap.sh. When an existing profile does not publish every port in the array, setup-profile.sh stops and prints that command instead of starting a cluster that would fail later. Chapter 02 needs none of these ports.
On the first request after an install, pods may not be Ready yet, so the demos call curl --retry 10 --retry-all-errors --retry-delay 1.
Build, run, observe
Run the preflight first. It needs no cluster.
[host]$ cd examples/01-lab-setup && ./demo.sh offline
It checks that helm resolves to .tools/bin/helm, that the pinned versions are present, that the two plugins are installed, and that every script under scripts/ passes bash -n. A run printed these lines:
==> Project-local Helm 4
ok: helm v4.3.0+gbec5b06
ok: helm resolves to .tools/bin
ok: HELM_CACHE_HOME is project-local
==> Global Helm is left alone
ok: global helm still reports: v3.18.3+g6838ebc
Then build the lab, which takes several minutes the first time.
[host]$ ./demo.sh
When it finishes, confirm the cluster independently of the script.
[host]$ source ../../scripts/env.sh && helm plugin list
The listing shows diff 3.15.15 and unittest 1.2.1, both of type cli/v1.
Cross-check
Compare Helm’s own view of its directories with env.sh. helm env prints HELM_CACHE_HOME, HELM_CONFIG_HOME, HELM_DATA_HOME and HELM_PLUGINS; all four must start with your repository path. In a second terminal that has not sourced env.sh, helm version --short still prints the global version, which confirms the isolation works in both directions. scripts/platform/cluster-status.sh then reports the cluster side: the API server, the registry addon, both operators and their CRDs, and the observability pods.
What you learned
scripts/env.shputs Helm 4 first onPATH, moves the fourHELM_*locations into.tools/helm/, and refuses to continue unless Helm is version 4.scripts/install-tools.shinstalls checksum-verified, pinned tools into.tools/only.- The bootstrap installs operators and observability, never application resources, so charts own the Postgres and Kafka custom resources.
- Images are built once; every later chapter changes only charts and values.
Chapter 02 uses this lab to install a public chart and tours what changed from Helm 3 to Helm 4.
Further reading
- Helm project, Helm 4 overview: the content-based cache, the plugin system and the renamed flags.
- Helm project, Installing Helm: the upstream install paths this lab replaces with a pinned, checksum-verified script.
Verification status: verified on 2026-10-08, evidence _plans/evidence/01-lab-setup.txt and _plans/evidence/01-lab-setup-fresh.txt. Observed on the live profile: ./demo.sh offline preflight passes, cluster-status.sh reports the platform healthy, the global Helm still reports v3.18.3, helm env and helm plugin list match the text, and the service image runs Python 3.14.8. The from-scratch run deleted the helm4dev profile and rebuilt it with setup-profile.sh (43 s), bootstrap.sh (235 s) and build-images.sh (11 s, warm Docker layer cache); every step exited 0 and cluster-status.sh ended with ok: platform healthy. Re-run on r1.1 on 2026-10-08 with published NodePorts (_plans/evidence/01-lab-setup-nodeports.txt): setup-profile.sh refused the old profile (port mismatch), --replace --confirm=helm4dev recreated it with all nine HFD_NODE_PORTS published, bootstrap.sh, build-images.sh and build-images.sh push succeeded, 127.0.0.1:5000/v2/_catalog lists both images, http://127.0.0.1:30300/api/health answers, and a second setup-profile.sh accepted the matching profile. Bindings were then tightened to loopback (127.0.0.1:<p>:<p>): the guard refused the 0.0.0.0-bound profile, the recreated profile shows every port bound to 127.0.0.1 in docker inspect and ss -ltn, and the registry and Grafana answer on 127.0.0.1.