Helm and Package Management

What Helm solves, a real Chart structure, and deploying with helm install and helm upgrade.

The problem Helm solves

A real application's Kubernetes manifests quickly grow past a single Deployment and Service — a ConfigMap, a Secret, an HPA, maybe an Ingress (covered on the next page), each with its own YAML file. Deploying the same application to staging and production means keeping several near-identical copies of all of that in sync, differing only in a handful of values like replica count, image tag, or resource limits — copy-pasted YAML that drifts out of sync the moment someone updates one environment and forgets the other.

Helm is Kubernetes' package manager: it lets you template a set of related manifests once (a chart), parameterize the parts that legitimately differ per environment, and install or upgrade the whole bundle as a single named unit (a release) with one command. The relationship between Helm and raw manifests is similar to the relationship between a package manager like npm/Composer and manually downloading and wiring up library files by hand — the underlying Kubernetes objects are exactly the same either way; Helm just manages the templating, versioning, and lifecycle around producing them.

Chart structure

A chart is a directory with a specific, conventional layout:

Plaintext
my-app/
 ├── Chart.yaml           # chart metadata: name, version, description
 ├── values.yaml          # default configuration values
 ├── templates/
 │    ├── deployment.yaml
 │    ├── service.yaml
 │    ├── configmap.yaml
 │    └── _helpers.tpl    # reusable named template snippets
 └── charts/              # any subcharts (dependencies) this chart bundles

Chart.yaml identifies the chart itself:

YAML
# Chart.yaml
apiVersion: v2
name: my-app
description: A Helm chart for my-app
version: 1.4.0
appVersion: "2.1.0"

version is the chart's own version (bump it whenever the templates or default values change); appVersion is the version of the application it deploys — the two are independent and don't have to move together, since a chart's templates can change without the application itself changing at all.

values.yaml holds the default configuration every template reads from:

YAML
# values.yaml
replicaCount: 3

image:
  repository: my-app
  tag: "1.2"

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

service:
  type: ClusterIP
  port: 80

And a template inside templates/ references those values with Helm's Go-template syntax ({{ .Values.* }}), producing a normal Kubernetes Deployment manifest once rendered:

YAML
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}
    spec:
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            requests:
              cpu: {{ .Values.resources.requests.cpu }}
              memory: {{ .Values.resources.requests.memory }}
            limits:
              cpu: {{ .Values.resources.limits.cpu }}
              memory: {{ .Values.resources.limits.memory }}

{{ .Release.Name }} is a Helm built-in referring to the name given at install time (covered next); {{ .Values.replicaCount }} pulls straight from values.yaml — or from an override supplied at install/upgrade time, which is exactly what makes one chart reusable across environments.

helm install and helm upgrade

Installing a chart creates a release — a named, tracked instance of that chart's rendered manifests, applied to the cluster:

Bash
helm install my-app-staging ./my-app \
  --set replicaCount=2 \
  --set image.tag=1.2

helm install my-app-prod ./my-app \
  --values values-production.yaml

--set overrides a single value inline; --values values-production.yaml supplies a whole file of overrides layered on top of the chart's defaults — the standard way to keep an environment-specific values file (values-production.yaml, values-staging.yaml) alongside the chart itself, containing only the values that actually differ from the defaults.

YAML
# values-production.yaml
replicaCount: 5
image:
  tag: "2.1.0"
resources:
  requests:
    cpu: "250m"
    memory: "256Mi"

Deploying a new version is helm upgrade, not a repeated install — Helm diffs the new rendered manifests against the currently deployed release and applies only what changed:

Bash
helm upgrade my-app-prod ./my-app \
  --values values-production.yaml \
  --set image.tag=2.2.0
Bash
$ helm list
NAME            NAMESPACE   REVISION   STATUS      CHART        APP VERSION
my-app-prod     default     4          deployed    my-app-1.4.0 2.2.0

$ helm rollback my-app-prod 3
Rollback was a success! Happy Helming!

Every helm upgrade creates a new numbered revision, and helm rollback <release> <revision> reverts to any previous one — the Helm-level equivalent of kubectl rollout undo, but covering the entire chart's rendered output (Deployment, Service, ConfigMap, and anything else the chart manages) as one atomic unit instead of a single Deployment alone.

Common mistakes

  • Editing a running release's Kubernetes objects directly with kubectl edit instead of through helm upgrade — the next helm upgrade (or even a helm diff) can silently overwrite that manual change, since Helm has no record of it.
  • Hardcoding environment-specific values (an image tag, a replica count) inside the templates themselves instead of values.yaml — this defeats the entire point of a reusable chart and puts you back to copy-pasting near-identical YAML per environment.
  • Forgetting that Chart.yaml's version and appVersion are independent — bumping the application without bumping the chart version (or vice versa) is fine, but conflating the two leads to confusing release history.
  • Running helm install again instead of helm upgrade when deploying a change to an existing release — install expects the release name to not already exist and will fail (or, with --replace, behave in a way that loses revision history) rather than performing the incremental update you actually want.