Lesson 58 of 66 · 20 min read · Updated

Packaging a .NET API with Helm Charts

Up to this point in the series you have deployed everything with kubectl apply -f and a handful of YAML files. That works for one application in one environment. It stops working the day you need the same API in development, staging and production, each with a different replica count, different resource limits and a different configuration value. You end up with three copies of every manifest, and they drift apart.

Helm is the package manager for Kubernetes. A Helm chart is a folder of templated manifests plus a file of default values. You install the chart with different values per environment, and Helm keeps a numbered history of every release so you can upgrade and roll back with one command.

In this article we package a .NET 10 minimal API as a Helm chart, install it into two environments, upgrade it with a database migration hook, roll it back, and publish the chart to an OCI registry. Everything is tested with Helm 4, which changed a few defaults you need to know about.

We will cover the following topics:

  1. What a chart, a release and a revision are
  2. What changed in Helm 4 compared to Helm 3
  3. The structure of a chart and how templates, values and helpers fit together
  4. Per-environment values files and validating values with a JSON schema
  5. helm template, lint, install, upgrade, rollback, history and uninstall
  6. Running a database migration Job with a pre-upgrade hook
  7. Packaging a chart and pushing it to an OCI registry

Technical Requirements

  1. A Kubernetes cluster and kubectl. The examples run on Docker Desktop's built-in Kubernetes with the kind provisioner (1 control-plane node and 3 workers, Kubernetes v1.36). A plain kind create cluster with the same node layout behaves the same.
  2. Helm 4 (helm version should print v4.x). The examples use v4.3.1.
  3. Docker and the .NET 10 SDK to build the sample image.
  4. Optional: the registry:2 container image, to try pushing a chart to a local OCI registry.

Core Concepts

Chart, Release and Revision

Three words carry most of Helm's vocabulary.

TermMeaning
ChartA package: templates, default values and metadata. Like a NuGet package for Kubernetes manifests.
ReleaseOne installation of a chart into a namespace, with a name you choose. The same chart can be installed many times as different releases.
RevisionA numbered version of a release. Every install, upgrade and rollback creates a new revision.

Helm stores each revision as a Secret of type helm.sh/release.v1 in the release's namespace. There is no server component. Everything Helm knows about a release lives in those Secrets, which is why helm history works from any machine with access to the cluster.

What Changed in Helm 4

Helm 4 was the first major release in six years. Charts written for Helm 3 (apiVersion: v2) keep working unchanged. The differences are in how Helm talks to the cluster and in some command-line flags.

AreaHelm 3Helm 4
Applying resourcesClient-side three-way mergeServer-side apply for new releases. Upgrades keep whatever method the release was created with, unless you pass --server-side.
--waitChecked a few resource kindsUses kstatus to judge readiness, and reports why a resource is not ready
Roll back on failure--atomic--rollback-on-failure (old name still works, prints a deprecation warning)
Replace resources--force--force-replace
Post-renderersAny executableMust be a Helm plugin
helm registry loginAccepted a full URLDomain name only
Install by digestNot supportedoci://registry/chart@sha256:...
Charts v3Not availableExperimental, behind HELM_EXPERIMENTAL_CHART_V3=1

You can see server-side apply at work after an install. The managedFields of a resource Helm created show operation: Apply with the manager helm, where Helm 3 showed operation: Update.

If your CI pipeline uses --atomic, --force or a post-renderer script, update it before upgrading. The rest of your workflow stays the same.

The Structure of a Chart

helm create scaffolds a working chart. Helm 4's scaffold now includes an httproute.yaml for Gateway API next to the classic ingress.yaml, both disabled by default. We trim the scaffold down to what the Catalog API needs:

catalog-api/
Chart.yaml # name, chart version, app version
values.yaml # default values
values.schema.json # optional: validates values before rendering
.helmignore # files to leave out of the package
templates/
_helpers.tpl # reusable named templates
configmap.yaml
deployment.yaml
service.yaml
migration-job.yaml # a hook, runs before install and upgrade
NOTES.txt # printed after install/upgrade

Chart.yaml carries two different versions, and mixing them up is the most common Helm mistake.

FieldWhat it versionsWhen to bump it
versionThe chart itself (templates, defaults)Every time you change anything in the chart. Must be SemVer.
appVersionThe application the chart deploysEvery time you release a new image. Informational, shown in helm list.

Templates, Values and Helpers

Templates are Go templates with the Sprig function library. Inside a template you read four main objects:

ObjectExampleContains
.Values.Values.replicaCountMerged values: values.yaml, then every -f file, then every --set
.Release.Release.Name, .Release.NamespaceFacts about this installation
.Chart.Chart.AppVersionFields of Chart.yaml
.Capabilities.Capabilities.KubeVersionWhat the target cluster supports

Files in templates/ whose names start with an underscore are not rendered as manifests. _helpers.tpl holds named templates defined with define and used with include. The scaffold gives you the important ones: a full name truncated to 63 characters, a standard label set, and the selector labels. Use them everywhere so every resource of a release carries the same labels.

Values merge from lowest to highest priority: chart values.yaml, then -f files in the order given, then --set, --set-string and --set-json flags. Later sources win, and maps merge key by key instead of being replaced.

Hooks

A hook is a normal manifest with a helm.sh/hook annotation. Helm creates it at a specific point of the release life cycle instead of together with the other resources. With pre-install,pre-upgrade and a Job, Helm runs the Job, waits for it to complete, and only then applies the Deployment. If the Job fails, the upgrade fails and the old Pods keep running. That is exactly what you want for database migrations.

AnnotationPurpose
helm.sh/hookWhen to run: pre-install, post-install, pre-upgrade, post-upgrade, pre-rollback, pre-delete, test and more
helm.sh/hook-weightOrder between hooks of the same phase, lowest first
helm.sh/hook-delete-policyWhen Helm deletes the hook object: before-hook-creation, hook-succeeded, hook-failed

Hooks are not part of the release's managed resources. helm uninstall does not delete them. A delete policy is how you keep them from piling up.

Hands-On: Kubernetes Commands

Scaffold a new chart

Creates a working chart you can trim.

helm create catalog-api

Render templates locally without a cluster

The fastest way to see exactly what Helm will send to Kubernetes.

helm template catalog-api ./catalog-api -f values-prod.yaml

Render a single template

Useful while you edit one file.

helm template catalog-api ./catalog-api --show-only templates/deployment.yaml

Check the chart for mistakes

--strict turns warnings into failures, which suits CI.

helm lint ./catalog-api -f values-prod.yaml --strict

Install, or upgrade if it already exists

The idempotent form most pipelines use.

helm upgrade --install catalog-api ./catalog-api -n catalog --create-namespace -f values-prod.yaml --wait

List releases in every namespace

helm list -A

See the values a release was installed with

Add --all to include the chart defaults.

helm get values catalog-api -n catalog

See the rendered manifests of a release

helm get manifest catalog-api -n catalog

Show the revision history and roll back

helm history catalog-api -n catalog
helm rollback catalog-api 1 -n catalog --wait

Remove a release

helm uninstall catalog-api -n catalog

Step-by-Step Example

The Scenario

The Catalog API is a .NET 10 minimal API. It returns its version, the ASP.NET Core environment name and a greeting read from configuration. It also has a --migrate mode that stands in for an EF Core migration bundle, so we can run it as a pre-upgrade Job. We will install it as two releases, one per environment, then upgrade, break, roll back and publish the chart.

Step 1 — The Application

Create the project:

dotnet new web -n CatalogApi
cd CatalogApi

Replace Program.cs:

if (args.Contains("--migrate"))
{
// Stand-in for `dotnet ef database update` or an EF Core migration bundle.
Console.WriteLine($"[migrate] Applying migrations for version {AppVersion()}...");
await Task.Delay(TimeSpan.FromSeconds(3));
Console.WriteLine("[migrate] Database is up to date.");
return;
}

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", (IConfiguration config) => Results.Ok(new
{
service = "catalog-api",
version = AppVersion(),
environment = app.Environment.EnvironmentName,
greeting = config["Catalog:Greeting"] ?? "Hello from catalog-api",
pod = Environment.MachineName
}));

app.MapGet("/healthz", () => Results.Ok("healthy"));

app.Run();

static string AppVersion() => Environment.GetEnvironmentVariable("APP_VERSION") ?? "dev";

Notice config["Catalog:Greeting"]. ASP.NET Core maps the environment variable Catalog__Greeting (double underscore) to that key, which is how the chart will inject configuration.

Add a Dockerfile and a .dockerignore containing bin/ and obj/:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish CatalogApi.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "CatalogApi.dll"]

Build two versions:

docker build --build-arg APP_VERSION=1.0.0 -t catalog-api:1.0.0 .
docker build --build-arg APP_VERSION=1.1.0 -t catalog-api:1.1.0 .

On Docker Desktop's Kubernetes the cluster can use these local images directly. On a plain kind cluster, load them first with kind load docker-image catalog-api:1.0.0 catalog-api:1.1.0. The first Pod on each node may sit in ContainerCreating for a while as the image is copied to that node.

Step 2 — Scaffold and Trim the Chart

helm create catalog-api
cd catalog-api
rm templates/hpa.yaml templates/ingress.yaml templates/httproute.yaml templates/serviceaccount.yaml
rm -r templates/tests

Remove the serviceAccountName helper from _helpers.tpl as well, since we no longer create a ServiceAccount. Then replace Chart.yaml:

apiVersion: v2
name: catalog-api
description: Catalog API, a .NET 10 minimal API
type: application
version: 0.1.0
appVersion: "1.0.0"

Step 3 — Default Values

values.yaml documents every knob the chart offers. Keep defaults production-safe; environments override what they need.

replicaCount: 2

image:
repository: catalog-api
# Defaults to .Chart.AppVersion when empty.
tag: ""
pullPolicy: IfNotPresent

environment: Production

config:
greeting: "Hello from catalog-api"

service:
type: ClusterIP
port: 80

containerPort: 8080

resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi

migrations:
enabled: true

Step 4 — The Templates

The ConfigMap turns a value into the environment variable the .NET configuration system reads:

apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "catalog-api.fullname" . }}
labels:
{{- include "catalog-api.labels" . | nindent 4 }}
data:
Catalog__Greeting: {{ .Values.config.greeting | quote }}

The Deployment. Two details deserve attention. The image tag falls back to appVersion, so bumping the chart's appVersion is enough to release a new image. The checksum/config annotation hashes the rendered ConfigMap: when the greeting changes, the Pod template changes, and the Deployment rolls new Pods. Without it, Pods would keep the old environment variables forever.

apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "catalog-api.fullname" . }}
labels:
{{- include "catalog-api.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "catalog-api.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "catalog-api.selectorLabels" . | nindent 8 }}
annotations:
# Roll the Pods when the ConfigMap content changes.
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.containerPort }}
env:
- name: ASPNETCORE_ENVIRONMENT
value: {{ .Values.environment | quote }}
envFrom:
- configMapRef:
name: {{ include "catalog-api.fullname" . }}
readinessProbe:
httpGet:
path: /healthz
port: http
livenessProbe:
httpGet:
path: /healthz
port: http
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}

The Service:

apiVersion: v1
kind: Service
metadata:
name: {{ include "catalog-api.fullname" . }}
labels:
{{- include "catalog-api.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "catalog-api.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http

The migration hook. It uses the same image as the API, runs with --migrate, and is created before install and before every upgrade. The delete policy before-hook-creation keeps the finished Job around so you can read its logs, and deletes it just before the next upgrade creates a new one.

{{- if .Values.migrations.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "catalog-api.fullname" . }}-migrate
labels:
{{- include "catalog-api.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "0"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["--migrate"]
{{- end }}

Finally NOTES.txt, which Helm renders and prints after every install and upgrade:

{{ include "catalog-api.fullname" . }} {{ .Values.image.tag | default .Chart.AppVersion }} is deployed to namespace {{ .Release.Namespace }}.

Try it:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "catalog-api.fullname" . }} 8080:{{ .Values.service.port }}
curl http://localhost:8080/

Step 5 — Values per Environment

Keep environment files outside the chart folder. They describe your deployments, not the package, and they should not ship inside the chart archive. Each file contains only what differs from the defaults.

values-dev.yaml:

replicaCount: 1
environment: Development
config:
greeting: "Hello from the dev cluster"
migrations:
enabled: false

values-prod.yaml:

replicaCount: 3
environment: Production
config:
greeting: "Hello from production"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi

Step 6 — Validate Values with a Schema

Templates happily render nonsense. Without a schema, --set replicaCount=abc produces replicas: abc and you only find out when the API server rejects it. Add values.schema.json to the chart root:

{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["replicaCount", "image", "environment"],
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 },
"environment": { "enum": ["Development", "Staging", "Production"] },
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string", "minLength": 1 },
"tag": { "type": "string" },
"pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
}
}
}
}

Now mistakes are caught before anything reaches the cluster:

helm template x ./catalog-api --set replicaCount=abc
Error: values don't meet the specifications of the schema(s) in the following chart(s):
catalog-api:
- at '/replicaCount': got string, want integer

helm template x ./catalog-api --set environment=Prod
Error: values don't meet the specifications of the schema(s) in the following chart(s):
catalog-api:
- at '/environment': value must be one of 'Development', 'Staging', 'Production'

Lint and render once more to be sure everything is in order:

helm lint ./catalog-api -f values-prod.yaml --strict
==> Linting ./catalog-api
[INFO] Chart.yaml: icon is recommended

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

Step 7 — Install Two Environments

The same chart, two releases, two namespaces:

helm install catalog-api ./catalog-api -n catalog-dev --create-namespace -f values-dev.yaml --wait --timeout 3m
helm install catalog-api ./catalog-api -n catalog-prod --create-namespace -f values-prod.yaml --wait --timeout 3m
NAME: catalog-api
LAST DEPLOYED: Wed Oct 21 11:25:50 2026
NAMESPACE: catalog-prod
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
catalog-api 1.0.0 is deployed to namespace catalog-prod.

Try it:
kubectl -n catalog-prod port-forward svc/catalog-api 8080:80
curl http://localhost:8080/

Because the release name catalog-api already contains the chart name, the fullname helper does not prefix it, and every resource is simply called catalog-api.

helm list -A
NAME NAMESPACE REVISION STATUS CHART APP VERSION
catalog-api catalog-dev 1 deployed catalog-api-0.1.0 1.0.0
catalog-api catalog-prod 1 deployed catalog-api-0.1.0 1.0.0

kubectl get deploy,svc,cm,pods -n catalog-prod
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/catalog-api 3/3 3 3 9s

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/catalog-api ClusterIP 10.96.125.145 <none> 80/TCP 9s

NAME DATA AGE
configmap/catalog-api 1 9s

NAME READY STATUS RESTARTS AGE
pod/catalog-api-5948b9965d-772ml 1/1 Running 0 9s
pod/catalog-api-5948b9965d-g668q 1/1 Running 0 9s
pod/catalog-api-5948b9965d-k68bn 1/1 Running 0 9s

Confirm Helm 4 used server-side apply:

kubectl get deploy catalog-api -n catalog-prod -o yaml --show-managed-fields | grep -E "manager:|operation:"
manager: helm
operation: Apply
manager: kube-controller-manager
operation: Update

Call the production API:

kubectl -n catalog-prod port-forward svc/catalog-api 8080:80
curl http://localhost:8080/
{"service":"catalog-api","version":"1.0.0","environment":"Production","greeting":"Hello from production","pod":"catalog-api-5948b9965d-772ml"}

helm get values shows only what you supplied; --all adds the chart defaults:

helm get values catalog-api -n catalog-prod
USER-SUPPLIED VALUES:
config:
greeting: Hello from production
environment: Production
replicaCount: 3
resources:
limits:
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi

Step 8 — Upgrade with a Migration Hook

Release version 1.1.0 to production. Pass the same values file again: Helm does not remember -f files between upgrades unless you add --reuse-values, and relying on that is fragile.

helm upgrade catalog-api ./catalog-api -n catalog-prod -f values-prod.yaml --set image.tag=1.1.0 --wait --timeout 3m
Release "catalog-api" has been upgraded. Happy Helming!
NAME: catalog-api
LAST DEPLOYED: Wed Oct 21 11:26:14 2026
NAMESPACE: catalog-prod
STATUS: deployed
REVISION: 2

The migration Job ran first, and is still there for inspection:

kubectl get jobs -n catalog-prod
NAME STATUS COMPLETIONS DURATION AGE
catalog-api-migrate Complete 1/1 7s 10s

kubectl logs job/catalog-api-migrate -n catalog-prod
[migrate] Applying migrations for version 1.1.0...
[migrate] Database is up to date.

kubectl port-forward to a Service actually connects to one Pod, and that Pod was just replaced. Stop the old port-forward and start it again, then call the API, which now reports the new version:

curl http://localhost:8080/
{"service":"catalog-api","version":"1.1.0","environment":"Production","greeting":"Hello from production","pod":"catalog-api-7d87dbfc55-pzdc7"}

helm list still shows APP VERSION 1.0.0. That column comes from appVersion in Chart.yaml, not from the tag you set. In a real release pipeline you bump appVersion (and the chart version) instead of overriding the tag, so that helm list tells the truth.

Step 9 — A Failed Upgrade That Rolls Itself Back

Deploy a tag that does not exist. --rollback-on-failure (formerly --atomic) implies --wait, and if the release is not ready before the timeout, Helm rolls back automatically:

helm upgrade catalog-api ./catalog-api -n catalog-prod -f values-prod.yaml --set image.tag=9.9.9 --set migrations.enabled=false --rollback-on-failure --timeout 60s
level=WARN msg="upgrade failed" name=catalog-api error="resource Deployment/catalog-prod/catalog-api not ready. status: InProgress, message: Updated: 1/3\ncontext deadline exceeded"
Error: UPGRADE FAILED: release catalog-api failed, and has been rolled back due to rollback-on-failure being set: resource Deployment/catalog-prod/catalog-api not ready. status: InProgress, message: Updated: 1/3
context deadline exceeded

The kstatus-based wait tells you why it gave up: only one of three replicas was updated. While this happened, the old Pods kept serving traffic, because the rolling update never got far enough to remove them.

The old flag name still works, with a warning:

helm upgrade catalog-api ./catalog-api -n catalog-prod -f values-prod.yaml --atomic ...
Flag --atomic has been deprecated, use --rollback-on-failure instead

Step 10 — History and Manual Rollback

helm history catalog-api -n catalog-prod
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Wed Oct 21 11:25:50 2026 superseded catalog-api-0.1.0 1.0.0 Install complete
2 Wed Oct 21 11:26:14 2026 superseded catalog-api-0.1.0 1.0.0 Upgrade complete
3 Wed Oct 21 11:26:31 2026 failed catalog-api-0.1.0 1.0.0 Upgrade "catalog-api" failed: resource Deployment/...
4 Wed Oct 21 11:27:31 2026 deployed catalog-api-0.1.0 1.0.0 Rollback to 2

A rollback never rewrites history. It creates a new revision whose content is a copy of an old one. Roll back to the very first install:

helm rollback catalog-api 1 -n catalog-prod --wait
Rollback was a success! Happy Helming!

helm status catalog-api -n catalog-prod
NAME: catalog-api
LAST DEPLOYED: Wed Oct 21 11:27:38 2026
NAMESPACE: catalog-prod
STATUS: deployed
REVISION: 5
DESCRIPTION: Rollback to 1

kubectl get deploy catalog-api -n catalog-prod -o jsonpath='{.spec.template.spec.containers[0].image}'
catalog-api:1.0.0

Every one of those revisions is a Secret in the namespace:

kubectl get secrets -n catalog-prod -l owner=helm
NAME TYPE DATA AGE
sh.helm.release.v1.catalog-api.v1 helm.sh/release.v1 1 111s
sh.helm.release.v1.catalog-api.v2 helm.sh/release.v1 1 87s
sh.helm.release.v1.catalog-api.v3 helm.sh/release.v1 1 70s
sh.helm.release.v1.catalog-api.v4 helm.sh/release.v1 1 10s
sh.helm.release.v1.catalog-api.v5 helm.sh/release.v1 1 3s

Helm keeps ten revisions per release by default. Change it with --history-max on upgrade.

Step 11 — Change Configuration Only

Update the dev greeting without touching the image. upgrade --install works whether the release exists or not:

helm upgrade --install catalog-api ./catalog-api -n catalog-dev -f values-dev.yaml --set config.greeting="Hi from upgrade --install" --wait

kubectl get cm catalog-api -n catalog-dev -o jsonpath='{.data}'
{"Catalog__Greeting":"Hi from upgrade --install"}

kubectl get pods -n catalog-dev
NAME READY STATUS RESTARTS AGE
catalog-api-57bbd7c66f-fgrzx 0/1 Completed 0 2m19s
catalog-api-5d9cd69b44-4jdhm 1/1 Running 0 1s

A new Pod appeared. That is the checksum/config annotation doing its job.

For structured values, Helm 4's --set-json is easier than a chain of --set flags. Maps merge with the existing values instead of replacing them:

helm template catalog-api ./catalog-api --set-json 'resources={"requests":{"cpu":"10m","memory":"32Mi"}}' --show-only templates/deployment.yaml
...
resources:
limits:
memory: 256Mi
requests:
cpu: 10m
memory: 32Mi

Step 12 — Package and Push to an OCI Registry

Charts are distributed as OCI artifacts, stored in the same registries as your container images: Azure Container Registry, GitHub Container Registry, Docker Hub, Harbor and others. To try it locally, start a throwaway registry. Port 5050 is used here because macOS reserves port 5000 for AirPlay Receiver:

docker run -d --name chart-registry -p 5050:5000 registry:2

Package the chart into a versioned archive, then push it. --plain-http is only needed because the local registry has no TLS:

helm package ./catalog-api
Successfully packaged chart and saved it to: catalog-api-0.1.0.tgz

helm push catalog-api-0.1.0.tgz oci://localhost:5050/charts --plain-http
Pushed: localhost:5050/charts/catalog-api:0.1.0
Digest: sha256:cda05f9cfaef7b3b4f94fd3f9ffac563dbb50c737d9828be4224e1ea055e7c8f

Anyone with access can now inspect and install it without the source folder:

helm show chart oci://localhost:5050/charts/catalog-api --version 0.1.0 --plain-http
apiVersion: v2
appVersion: 1.0.0
description: Catalog API, a .NET 10 minimal API
name: catalog-api
type: application
version: 0.1.0

Helm 4 can install by digest, which pins the exact bytes you tested. A tag can be overwritten; a digest cannot:

helm install catalog-api oci://localhost:5050/charts/catalog-api@sha256:cda05f9cfaef7b3b4f94fd3f9ffac563dbb50c737d9828be4224e1ea055e7c8f --plain-http -n catalog-oci --create-namespace -f values-dev.yaml --wait
Pulled: localhost:5050/charts/catalog-api@sha256:cda05f9cfaef7b3b4f94fd3f9ffac563dbb50c737d9828be4224e1ea055e7c8f
...
STATUS: deployed

For a real registry you log in first. In Helm 4, helm registry login takes the host name only, for example helm registry login myregistry.azurecr.io, not a URL.

Step 13 — Clean Up

Uninstall the releases:

helm uninstall catalog-api -n catalog-prod
helm uninstall catalog-api -n catalog-dev
helm uninstall catalog-api -n catalog-oci

Look at what uninstall leaves behind in production:

kubectl get jobs -n catalog-prod
NAME STATUS COMPLETIONS DURATION AGE
catalog-api-migrate Complete 1/1 7s 2m7s

The hook Job survived, because hooks are not managed resources of the release. Deleting the namespaces removes it, along with everything else:

kubectl delete namespace catalog-dev catalog-prod catalog-oci
docker rm -f chart-registry

Summary

  1. A chart is a package of templates and default values; a release is one installation of it; every install, upgrade and rollback creates a numbered revision stored as a Secret in the namespace.
  2. version in Chart.yaml versions the chart, appVersion versions the application. Bump the right one.
  3. Helm 4 uses server-side apply for new releases and kstatus for --wait. --atomic is now --rollback-on-failure, --force is --force-replace, post-renderers must be plugins, and helm registry login takes a host name.
  4. Keep per-environment values files outside the chart and pass them with -f on every upgrade. Add values.schema.json so bad values fail before they reach the cluster.
  5. A checksum/config annotation makes configuration changes roll the Pods.
  6. A pre-install,pre-upgrade hook Job is the natural place for database migrations. Hooks are not deleted by helm uninstall; choose a delete policy.
  7. Charts are published to OCI registries with helm package and helm push, and Helm 4 can install them by digest.

In the next article we solve the same multi-environment problem without any templating at all, using Kustomize overlays.