Lesson 58 of 66 · 20 min read · Updated
Packaging a .NET API with Helm Charts
On this page
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:
- What a chart, a release and a revision are
- What changed in Helm 4 compared to Helm 3
- The structure of a chart and how templates, values and helpers fit together
- Per-environment values files and validating values with a JSON schema
helm template,lint,install,upgrade,rollback,historyanduninstall- Running a database migration Job with a pre-upgrade hook
- Packaging a chart and pushing it to an OCI registry
Technical Requirements
- 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 plainkind create clusterwith the same node layout behaves the same. - Helm 4 (
helm versionshould printv4.x). The examples use v4.3.1. - Docker and the .NET 10 SDK to build the sample image.
- Optional: the
registry:2container 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.
| Term | Meaning |
|---|---|
| Chart | A package: templates, default values and metadata. Like a NuGet package for Kubernetes manifests. |
| Release | One installation of a chart into a namespace, with a name you choose. The same chart can be installed many times as different releases. |
| Revision | A 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.
| Area | Helm 3 | Helm 4 |
|---|---|---|
| Applying resources | Client-side three-way merge | Server-side apply for new releases. Upgrades keep whatever method the release was created with, unless you pass --server-side. |
--wait | Checked a few resource kinds | Uses 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-renderers | Any executable | Must be a Helm plugin |
helm registry login | Accepted a full URL | Domain name only |
| Install by digest | Not supported | oci://registry/chart@sha256:... |
| Charts v3 | Not available | Experimental, 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:
Chart.yaml carries two different versions, and mixing them up is the most common Helm mistake.
| Field | What it versions | When to bump it |
|---|---|---|
version | The chart itself (templates, defaults) | Every time you change anything in the chart. Must be SemVer. |
appVersion | The application the chart deploys | Every 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:
| Object | Example | Contains |
|---|---|---|
.Values | .Values.replicaCount | Merged values: values.yaml, then every -f file, then every --set |
.Release | .Release.Name, .Release.Namespace | Facts about this installation |
.Chart | .Chart.AppVersion | Fields of Chart.yaml |
.Capabilities | .Capabilities.KubeVersion | What 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.
| Annotation | Purpose |
|---|---|
helm.sh/hook | When to run: pre-install, post-install, pre-upgrade, post-upgrade, pre-rollback, pre-delete, test and more |
helm.sh/hook-weight | Order between hooks of the same phase, lowest first |
helm.sh/hook-delete-policy | When 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.
Render templates locally without a cluster
The fastest way to see exactly what Helm will send to Kubernetes.
Render a single template
Useful while you edit one file.
Check the chart for mistakes
--strict turns warnings into failures, which suits CI.
Install, or upgrade if it already exists
The idempotent form most pipelines use.
List releases in every namespace
See the values a release was installed with
Add --all to include the chart defaults.
See the rendered manifests of a release
Show the revision history and roll back
Remove a release
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:
Replace Program.cs:
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/:
Build two versions:
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
Remove the serviceAccountName helper from _helpers.tpl as well, since we no longer create a ServiceAccount. Then replace Chart.yaml:
Step 3 — Default Values
values.yaml documents every knob the chart offers. Keep defaults production-safe; environments override what they need.
Step 4 — The Templates
The ConfigMap turns a value into the environment variable the .NET configuration system reads:
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.
The Service:
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.
Finally NOTES.txt, which Helm renders and prints after every install and upgrade:
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:
values-prod.yaml:
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:
Now mistakes are caught before anything reaches the cluster:
Lint and render once more to be sure everything is in order:
Step 7 — Install Two Environments
The same chart, two releases, two namespaces:
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.
Confirm Helm 4 used server-side apply:
Call the production API:
helm get values shows only what you supplied; --all adds the chart defaults:
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.
The migration Job ran first, and is still there for inspection:
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:
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:
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:
Step 10 — History and Manual Rollback
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:
Every one of those revisions is a Secret in the namespace:
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:
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:
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:
Package the chart into a versioned archive, then push it. --plain-http is only needed because the local registry has no TLS:
Anyone with access can now inspect and install it without the source folder:
Helm 4 can install by digest, which pins the exact bytes you tested. A tag can be overwritten; a digest cannot:
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:
Look at what uninstall leaves behind in production:
The hook Job survived, because hooks are not managed resources of the release. Deleting the namespaces removes it, along with everything else:
Summary
- 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.
versioninChart.yamlversions the chart,appVersionversions the application. Bump the right one.- Helm 4 uses server-side apply for new releases and kstatus for
--wait.--atomicis now--rollback-on-failure,--forceis--force-replace, post-renderers must be plugins, andhelm registry logintakes a host name. - Keep per-environment values files outside the chart and pass them with
-fon every upgrade. Addvalues.schema.jsonso bad values fail before they reach the cluster. - A
checksum/configannotation makes configuration changes roll the Pods. - A
pre-install,pre-upgradehook Job is the natural place for database migrations. Hooks are not deleted byhelm uninstall; choose a delete policy. - Charts are published to OCI registries with
helm packageandhelm 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.