Guides/KubernetesKubernetes/Helm: Packaging Kubernetes Applications

Helm: Packaging Kubernetes Applications

Helm packages Kubernetes manifests into templated charts: chart layout, values and templating, the install/upgrade/rollback release lifecycle, and Helm vs Kustomize.


Once you run a real application on Kubernetes, you stop having a manifest and start having a pile of them - a Deployment, a Service, a ConfigMap, an Ingress, maybe an HPA and a ServiceAccount - and then you multiply that pile by every environment. Dev wants 1 replica and a debug log level; prod wants 6 replicas, a real database URL, and TLS. Copy-pasting near-identical YAML across three folders is how you end up with a staging environment that quietly drifts from prod until something breaks in a way nobody can reproduce. Helm is the package manager that fixes this: it turns a set of manifests into a parameterized, versioned, installable unit called a chart. This guide covers what a chart is, how templating and values work, the release lifecycle you drive with helm install/upgrade/rollback, installing third-party charts, and an honest take on when to use Helm versus Kustomize instead.

The problem Helm solves

The Kubernetes objects for one app are individually simple - if you have read the configuration guide you already know most of them. The pain is not any single manifest; it is managing many of them together, across environments, over time. Three problems compound:

  • Duplication across environments. The dev, staging, and prod manifests are 90% identical and 10% different. Keeping them as three separate copies means every change has to be made three times, and the copies drift.
  • No unit of versioning or rollback. kubectl apply -f ./manifests applies a directory, but there is no "version 1.4.2 of my app" and no single command to roll the whole set back to the last good state.
  • No sharing. If you want to run Postgres, Prometheus, or cert-manager, you should not have to hand-author twenty manifests. Someone already did; you want to install their work like a package.

Helm addresses all three at once. A chart is the reusable package (the templated manifests plus metadata). Values are the per-environment inputs you feed it. A release is a named, versioned installation of a chart into a cluster - and because it is versioned, you can upgrade and roll it back as one atomic unit. Charts can be published to repositories, so installing someone else's app becomes a one-liner.

Charts, values, and templates

Three concepts carry the whole system, and they map cleanly onto the problems above:

  • A chart is a directory of templated Kubernetes manifests plus a Chart.yaml describing it. Think of it as the package definition - generic, not tied to any one environment.
  • Values are the parameters that fill in the template. values.yaml holds the defaults; you override them per environment (a values-prod.yaml, or --set flags on the command line). This is the 10% that differs.
  • A template is a manifest with placeholders like {{ .Values.replicaCount }}. At install time Helm renders every template against the merged values and produces plain Kubernetes YAML, which it then sends to the API server.

The mental model: chart + values -> rendered manifests -> a release in the cluster. The chart is written once. The values change per environment. The release is what actually exists in Kubernetes, tracked so it can be upgraded and rolled back.

The chart directory layout

A chart is just a directory with a specific structure. Running helm create web scaffolds one; the parts that matter are:

web/
  Chart.yaml          # chart metadata: name, version, appVersion
  values.yaml         # default configuration values
  templates/          # the templated manifests
    deployment.yaml
    service.yaml
    ingress.yaml
    _helpers.tpl      # reusable template snippets (named templates)
    NOTES.txt         # message printed after install
  charts/             # bundled subcharts (dependencies), if any

Chart.yaml is the package's identity card. Two version fields matter and they are not the same thing:

apiVersion: v2
name: web
description: The web frontend service
type: application
version: 1.4.2        # the version of THE CHART itself
appVersion: "2.7.0"   # the version of the app the chart deploys

version is the chart's own semantic version - bump it whenever you change the templates or defaults. appVersion is the version of the application being deployed (usually the container image tag) and is informational. Confusing the two is a common early mistake: shipping a new image but forgetting to bump version means helm upgrade sees no chart change to track.

values.yaml holds the defaults. templates/ holds the manifests, with two special files: _helpers.tpl (files starting with _ are not rendered as manifests - they hold reusable named templates like a standard label block) and NOTES.txt (printed to the terminal after a successful install, handy for "your app is available at ...").

How templating works

Templates are standard Kubernetes YAML with Go template directives spliced in. The core move is {{ .Values.something }}, which pulls a value from the merged values and drops it into the manifest. Here is a real, if trimmed, templates/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-web
  labels:
    app: {{ .Chart.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        app: {{ .Chart.Name }}
    spec:
      containers:
        - name: web
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          env:
            - name: LOG_LEVEL
              value: {{ .Values.logLevel | quote }}
{{- if .Values.resources }}
          resources:
{{ toYaml .Values.resources | indent 12 }}
{{- end }}

A few things are doing work here. {{ .Values.x }} reads your input. {{ .Release.Name }} and {{ .Chart.Name }} are built-in objects Helm always provides - the release name lets several installs of the same chart coexist without colliding. Pipes apply functions: | quote wraps a value in quotes (important so a value like yes or 1.20 is not misread as a boolean or number by YAML), and toYaml ... | indent 12 renders a whole values sub-tree (the resources block) as correctly-indented YAML. The {{- if .Values.resources }} block only emits the resources section when it is actually set - conditionals let one template serve environments that do and do not specify limits.

The matching values.yaml supplies the defaults these placeholders read:

replicaCount: 1

image:
  repository: myco/web
  tag: "2.7.0"

service:
  port: 80
  targetPort: 8080

logLevel: info

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 256Mi

Now the environment difference lives entirely in values, not in duplicated manifests. Dev runs on the defaults. For prod you write a small values-prod.yaml with only the overrides:

replicaCount: 6
logLevel: warn
image:
  tag: "2.7.0"

Helm deep-merges this on top of the chart's values.yaml, so you only state what differs. That is the payoff: one chart, thin per-environment value files, zero copy-pasted YAML.

Releases and the release lifecycle

Installing a chart creates a release - a named instance tracked in the cluster (Helm 3 stores release state as a Secret in the target namespace). Because the release is versioned, every operation on it is atomic and reversible.

# Install the chart as a release named "web-prod"
helm install web-prod ./web -f values-prod.yaml

# List releases in the current namespace (add -A for all namespaces)
helm list

# Change something and roll it out - same command whether it is a new
# image tag, more replicas, or a config change
helm upgrade web-prod ./web -f values-prod.yaml --set image.tag=2.8.0

# See the history of revisions for this release
helm history web-prod

# The new version is broken - roll the whole release back to revision 1
helm rollback web-prod 1

# Remove the release and everything it created
helm uninstall web-prod

The lifecycle in words: install creates the release at revision 1. Every upgrade renders the chart against the current values, diffs it against what is running, applies the changes, and records a new revision. helm history shows those revisions with their status. rollback re-applies a previous revision's rendered manifests in one command - this is the feature you cannot easily reproduce with raw kubectl, because Helm remembers exactly what every past revision looked like. uninstall deletes everything the release created.

Two flags earn their keep on upgrades. --atomic rolls the release back automatically if the upgrade fails to become healthy, so you never end up half-deployed. --install (as in helm upgrade --install) installs the release if it does not exist yet and upgrades it if it does - the idempotent form you want in CI pipelines, since it works on both the first deploy and every one after.

Repositories and third-party charts

The other half of Helm is not authoring charts but consuming them. A repository is an HTTP server hosting packaged charts and an index; you add one, then install charts from it by name. This is how you run production-grade infrastructure without writing the manifests yourself.

# Add a repository and refresh the local index
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# Find and inspect charts
helm search repo postgres
helm show values bitnami/postgresql       # dump the chart's configurable values

# Install a third-party chart, overriding a few values
helm install db bitnami/postgresql \
  --set auth.database=appdb \
  --set primary.persistence.size=20Gi

The workflow is always the same: helm show values <chart> to see every knob the chart exposes, then install with your overrides via -f my-values.yaml or --set. Increasingly charts are also published as OCI artifacts in a container registry, which you install by URL (helm install db oci://registry.example.com/charts/postgresql --version 15.1.2) with no helm repo add step. Pin the chart --version in anything real - "latest" is not reproducible, and an unpinned third-party chart is a supply-chain surprise waiting to happen.

Previewing with helm template and --dry-run

Because Helm renders templates into YAML before anything touches the cluster, you can and should look at that YAML first. Two tools do this, and they answer different questions.

# Render the chart to plain YAML locally - no cluster contact at all
helm template web-prod ./web -f values-prod.yaml

# Same, but validated against the live cluster's API without applying
helm upgrade web-prod ./web -f values-prod.yaml --dry-run

helm template renders offline. It is perfect for catching template bugs (a typo'd .Values path, wrong indentation, a broken conditional) and for GitOps, where you commit or hand the rendered manifests to another tool. It does not talk to the cluster, so it cannot validate against server-side schemas. --dry-run goes one step further: it sends the rendered manifests to the API server for validation but stops before applying, so it catches things helm template cannot, like a manifest the cluster's admission controllers would reject. The habit worth building: helm template | less (or pipe it through a diff tool) before every real upgrade, so you know exactly what is about to change instead of trusting the templates blindly.

Helm vs Kustomize: when each fits

Helm is not the only way to manage environment-specific manifests. Kustomize (built into kubectl as kubectl apply -k) solves the same duplication problem with a fundamentally different approach, and picking the right one matters.

  • Kustomize is template-free. You write plain, valid Kubernetes YAML as a base, then define overlays (dev, prod) that patch the base - override the replica count, add a label, swap an image tag. There are no {{ }} placeholders; every file is real YAML you could kubectl apply directly. This makes it easy to read and hard to get "clever" with, but it can get verbose when the differences between environments are large or numerous.
  • Helm is templated and packaged. You get full logic (conditionals, loops, functions), a single versioned artifact you can publish and share, and the install/upgrade/rollback release lifecycle. The cost is that a chart is Go-templated YAML, which is not valid YAML on its own and can become hard to read when it grows dense with {{- if }} blocks.

The practical split: reach for Kustomize when you own the manifests, the per-environment differences are small and structural (a few patches), and you value plain-YAML readability - it is lightweight and needs no extra tooling. Reach for Helm when you need to distribute an application for others to install, when you are consuming third-party software (almost everything ships as a Helm chart), or when you genuinely need templating logic and a real release/rollback lifecycle. They are not mutually exclusive: a common pattern is to render a Helm chart with helm template and then post-process it with Kustomize, getting the shared package from Helm and the last-mile patches from Kustomize. Start with Kustomize for your own simple apps; use Helm the moment you are packaging for others or installing what others packaged.

The shape of it

Helm exists because a real Kubernetes application is many manifests times many environments, and copy-pasted YAML drifts and breaks. A chart packages those manifests once; values capture the per-environment 10% that differs; templates splice values into manifests with {{ .Values.x }} and a bit of logic. Installing a chart creates a versioned release, and that versioning is what buys you helm upgrade, helm history, and one-command helm rollback - an atomic lifecycle you cannot easily get from raw kubectl. Repositories turn running third-party software into a one-liner, and helm template/--dry-run let you see exactly what will be applied before it is. When the job is your own simple app, Kustomize is often the lighter choice; when you are packaging for others or installing their work, Helm is the tool. Either way the goal is the same: stop hand-managing piles of near-identical YAML and start deploying versioned, reproducible units.