Helm and Package Management

Difficulty

The problem: deploying the same application across many environments

A real application's Kubernetes manifests (Deployment, Service, ConfigMap, Ingress, maybe an HPA) need slightly different values across dev, staging, and production — different replica counts, different resource limits, different hostnames. With plain YAML and kubectl apply, you either maintain entirely separate copies of every manifest per environment (duplicated, drifts out of sync over time) or bolt on your own ad-hoc templating (shell scripts with sed, or hand-rolled variable substitution) with no standard tooling around it.

What Helm adds: charts, values, and releases

mychart/
├── Chart.yaml           # chart metadata: name, version, description
├── values.yaml           # default configuration values
└── templates/
    ├── deployment.yaml    # Kubernetes manifest, with {{ .Values.x }} placeholders
    ├── service.yaml
    └── configmap.yaml
# templates/deployment.yaml (simplified)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-web
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: web
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
helm install my-app ./mychart --values production-values.yaml

A chart is the packaged, templated bundle of manifests; values parameterize it per environment/deployment without duplicating the underlying templates; a release is Helm's tracked record of one specific installation of a chart (with a specific set of values) into a cluster, versioned so you can see history and roll back.

What this buys you over raw kubectl apply

  • No manifest duplication across environments — one set of templates, different values files per environment.
  • Built-in release history and rollbackhelm rollback my-app 3 reverts to a previous release revision, without needing to have manually kept old YAML files around yourself.
  • Dependency management — a chart can declare dependencies on other charts (e.g., an application chart depending on a postgresql subchart), letting complex, multi-component applications be installed and versioned together as one unit.
  • A public ecosystem of pre-built charts — Artifact Hub and vendor-published charts let you install well-known software (Prometheus, cert-manager, ingress-nginx) with a single helm install, rather than hand-writing manifests for someone else's application from scratch.

What Helm doesn't change about the underlying model

Helm ultimately still produces and applies ordinary Kubernetes manifests — it's a layer of templating, packaging, and release tracking on top of the same declarative object model everything else in Kubernetes uses (see the fundamentals topic), not a replacement for understanding what a Deployment or Service actually is. Debugging a Helm-deployed application still means understanding the actual rendered Kubernetes objects underneath (helm template renders the final YAML without installing anything, useful for exactly this kind of inspection).

When plain kubectl apply is still perfectly fine

For a small number of simple, rarely-changing manifests, or a single-environment deployment with no real parameterization need, introducing Helm's additional layer of templating and tooling can be more complexity than the problem warrants — Helm earns its keep specifically once you have real per-environment variation, a genuine need for tracked release history/rollback, or want to consume other people's pre-packaged charts.

Related Resources

The standard chart layout

mychart/
├── Chart.yaml              # chart metadata
├── values.yaml               # default configuration values
├── charts/                   # bundled dependency charts (subcharts)
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── configmap.yaml
│   ├── _helpers.tpl          # reusable template snippets (not rendered as its own manifest)
│   └── NOTES.txt              # shown to the user after helm install/upgrade
└── .helmignore                # files to exclude when packaging the chart

Chart.yaml — the chart's own identity

apiVersion: v2
name: mychart
description: A Helm chart for my application
version: 1.2.0        # the CHART's own version
appVersion: "2.4.0"    # the version of the application it deploys (informational)
dependencies:
  - name: postgresql
    version: "12.x.x"
    repository: "https://charts.bitnami.com/bitnami"

Note the important distinction between version (the chart's own packaging version, incremented whenever the chart's templates/structure change) and appVersion (the version of the actual application inside, purely informational, not used by Helm's own versioning logic) — conflating these two is a common early mistake.

values.yaml — default configuration

replicaCount: 2
image:
  repository: myapp
  tag: "1.0.0"
resources:
  requests:
    cpu: "100m"
    memory: "128Mi"

Every value referenced in the templates ({{ .Values.replicaCount }}) has a default here — overridden per-install via --set replicaCount=5 or a separate --values production.yaml file, without ever touching the templates themselves.

templates/ — the actual manifest templates

Written in Go's templating language, with access to several built-in objects beyond just .Values:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-web        # .Release: info about this specific install
  labels:
    chart: {{ .Chart.Name }}-{{ .Chart.Version }}   # .Chart: this chart's own metadata
spec:
  replicas: {{ .Values.replicaCount }}   # .Values: user-supplied or default config

_helpers.tpl — reusable snippets, not a standalone manifest

{{- define "mychart.fullname" -}}
{{ .Release.Name }}-{{ .Chart.Name }}
{{- end -}}

Files starting with an underscore aren't rendered as Kubernetes manifests themselves — they define reusable named template snippets (like a function) that other templates can invoke ({{ include "mychart.fullname" . }}), avoiding repetition of common logic (like generating consistent resource names or labels) across many template files.

charts/ — bundled subchart dependencies

If Chart.yaml declares dependencies, running helm dependency update downloads and places them here as .tgz packages, letting a parent chart compose several other charts together (e.g., an application chart depending on a redis subchart for its caching layer) as one cohesive, installable unit.

NOTES.txt — post-install guidance

Rendered with the same templating engine and printed to the user's terminal immediately after a successful helm install/helm upgrade — commonly used to show next steps, like the command to retrieve a generated admin password, or the URL the application will be reachable at.

Installing creates a release

helm install my-app ./mychart --values production-values.yaml

my-app is now a release — a named, tracked instance of mychart installed into a specific namespace, at revision 1. Helm records this release's state (which chart version, which values were used, the fully-rendered manifests that resulted) internally, typically as a Secret in the same namespace.

Upgrading creates a new revision of the same release

helm upgrade my-app ./mychart --values production-values.yaml --set image.tag=2.0.0

This doesn't create a new release — it creates revision 2 of the existing my-app release, computing the diff between the previously-rendered manifests and the newly-rendered ones (based on the updated chart version and/or values) and applying just that diff to the cluster.

helm history my-app
# REVISION  UPDATED                   STATUS      CHART           APP VERSION
# 1         Mon Jan  1 10:00:00 2024  superseded  mychart-1.0.0   1.0.0
# 2         Tue Jan  2 14:30:00 2024  deployed    mychart-1.1.0   2.0.0

Rolling back

helm rollback my-app 1

Reverts the release back to revision 1's exact rendered manifest state — Helm re-applies revision 1's fully-rendered YAML, effectively undoing whatever revision 2 changed. This creates yet another new revision (e.g., revision 3) whose content matches revision 1 — Helm's history is append-only; a rollback is recorded as a new event in the timeline, not a deletion of revision 2 from history.

helm history my-app
# REVISION  UPDATED                   STATUS      CHART           APP VERSION
# 1         Mon Jan  1 10:00:00 2024  superseded  mychart-1.0.0   1.0.0
# 2         Tue Jan  2 14:30:00 2024  superseded  mychart-1.1.0   2.0.0
# 3         Wed Jan  3 09:15:00 2024  deployed    mychart-1.0.0   1.0.0   <- rollback, recorded as a new revision

How this relates to (and differs from) a Deployment's own rollback

A Kubernetes Deployment itself already has revision history and kubectl rollout undo (see the workload controllers topic) — but that only covers that one Deployment's Pod template changes. A Helm release's rollback operates one level higher: it can revert an entire chart's worth of resources together — a Deployment, a ConfigMap, a Service, an Ingress, all changed consistently as part of one upgrade — as a single coordinated rollback, rather than needing to manually roll back each affected object independently.

Helm's revision history is a powerful safety net for complex, multi-resource applications, but it's not a substitute for understanding what changed and why a rollback is needed — helm diff (a popular third-party plugin) or comparing helm get manifest output between revisions helps confirm exactly what a rollback will actually change before running it, rather than rolling back blind and hoping for the best.

Related Resources

Helm's approach: templating

# templates/deployment.yaml -- NOT valid standalone YAML, has template syntax
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-web
spec:
  replicas: {{ .Values.replicaCount }}

A Helm template file, by itself, isn't valid Kubernetes YAML — it only becomes valid once the templating engine substitutes in actual values. This gives Helm a lot of expressive power (conditionals, loops, functions, reusable snippets via _helpers.tpl), at the cost of templates being harder to read/lint directly (you're reading a mix of YAML and Go template syntax, not plain YAML) and occasionally producing subtly invalid YAML if template logic has bugs (e.g., incorrect indentation introduced by a template conditional).

Kustomize's approach: overlays and patches on valid YAML

base/
├── deployment.yaml     # PLAIN, valid, complete Kubernetes YAML -- no placeholders at all
└── kustomization.yaml
overlays/
└── production/
    ├── kustomization.yaml
    └── replica-patch.yaml    # a small patch, applied ON TOP of the base
# base/deployment.yaml -- perfectly valid, standalone Kubernetes YAML
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
# overlays/production/replica-patch.yaml -- a patch, not a template
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 10
# overlays/production/kustomization.yaml
resources:
  - ../../base
patchesStrategicMerge:
  - replica-patch.yaml
kubectl apply -k overlays/production/

The base/ directory is always plain, valid, directly-readable YAML — there's no templating syntax to parse mentally. Each environment's overlay declares which base to start from and what specific patches to layer on top, and Kustomize merges them at apply time — no separate templating language to learn, and the base manifests can be validated/linted as ordinary Kubernetes YAML on their own.

Key differences, side by side

HelmKustomize
MechanismTemplating (placeholders filled at render time)Overlays/patches on plain, valid base YAML
Base manifestsNot valid YAML on their own (contain template syntax)Always valid, standalone YAML
Built into kubectlNo — separate CLIYes (kubectl apply -k), also available as a standalone CLI
Release/version trackingYes — built-in release history, helm rollbackNo native concept of a "release" — you track and roll back via your own git history/CI process instead
Packaging and distribution ecosystemRich — Artifact Hub, versioned charts, dependency managementMinimal — no equivalent packaging/distribution convention
Learning curve for the templating/patching approach itselfSteeper (Go template syntax, functions, _helpers.tpl)Generally considered gentler (patches are just YAML)

When each tends to be preferred

Helm tends to be preferred when you need to install and manage complex third-party software (databases, ingress controllers, monitoring stacks) via a rich ecosystem of pre-built charts, or when built-in release/rollback tracking is genuinely valuable for your own applications. Kustomize tends to be preferred for organizations that want to keep manifests as plain, directly-readable YAML with no templating layer at all, relying on GitOps tooling and git history itself for change tracking and rollback rather than a separate release-tracking mechanism.

They aren't mutually exclusive

It's common to use both together — e.g., using Kustomize to manage environment-specific overlays for your own application's plain manifests, while still using Helm to install third-party charts (a monitoring stack, an ingress controller) that are only distributed as Helm charts in the first place.

Related Resources

Layering values files

# values.yaml -- the chart's baseline defaults
replicaCount: 2
image:
  repository: myapp
  tag: "1.0.0"
resources:
  requests:
    cpu: "100m"
    memory: "128Mi"
autoscaling:
  enabled: false
# values-production.yaml -- ONLY the values that differ from the defaults
replicaCount: 6
resources:
  requests:
    cpu: "500m"
    memory: "512Mi"
autoscaling:
  enabled: true
  minReplicas: 6
  maxReplicas: 20
helm install my-app ./mychart -f values.yaml -f values-production.yaml

Helm merges values files in the order given, with later files overriding earlier ones for any key they both specify — so values-production.yaml only needs to contain the handful of keys that genuinely differ for production, not a full duplicate copy of every setting.

Quick one-off overrides with --set

helm upgrade my-app ./mychart -f values-production.yaml --set image.tag=2.1.0

--set is convenient for a single, temporary override (like bumping just the image tag as part of a CI/CD pipeline step) without needing to edit or generate a values file — but it's harder to track/audit than a values file checked into version control, so it's generally better suited to programmatic use (a deploy script substituting in a build-specific value) than for hand-maintained, meaningful configuration differences.

A common project layout

mychart/
├── values.yaml                  # defaults
environments/
├── values-dev.yaml
├── values-staging.yaml
└── values-production.yaml

Keeping environment-specific values files outside the chart itself (rather than bundled inside it) is a common pattern, since it cleanly separates "what the chart is" (reusable, environment-agnostic templates and sensible defaults) from "how a specific environment configures it" (which changes far more often and is often owned/reviewed by a different part of the team, like a platform/SRE group rather than the application developers who own the chart's templates).

Secrets shouldn't live in plain values files

Values files are ordinary, unencrypted text — genuinely sensitive values (database passwords, API keys) shouldn't be committed directly in a values-production.yaml any more than they should in a raw Kubernetes Secret manifest (see the security topic's Secrets question). Common approaches: reference an already-existing Kubernetes Secret (created out-of-band, via an external secrets manager) from the chart's templates rather than passing the actual secret value through values.yaml at all, or use a values-file encryption tool (like helm-secrets, built on SOPS) so sensitive values files themselves can be safely committed in encrypted form.

Validating what will actually be applied, before applying it

helm template my-app ./mychart -f values-production.yaml

helm template renders the final Kubernetes manifests locally without installing anything — an essential sanity check before running a real helm upgrade against production, letting you review the exact resulting YAML (confirming the right image tag, replica count, resource limits actually got substituted correctly) rather than trusting the values-merging logic blindly.

Keep the base values.yaml as the single source of sensible, safe defaults; keep per-environment files minimal (only the actual deltas); use --set sparingly and mainly for CI/CD-driven, single-value substitutions; and never commit real secret values into any values file, encrypted or not, without a specific reason.

Related Resources