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:
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:
# 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:
# 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:
# 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:
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.
# 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:
helm upgrade my-app-prod ./my-app \
--values values-production.yaml \
--set image.tag=2.2.0
$ 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 editinstead of throughhelm upgrade— the nexthelm upgrade(or even ahelm 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'sversionandappVersionare 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 installagain instead ofhelm upgradewhen deploying a change to an existing release —installexpects 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.