How to Use Helm Charts in Kubernetes

How to Use Helm Charts in Kubernetes

The first time I had to deploy the same application to three environments — dev, staging, production — each with slightly different replica counts, resource limits, and hostnames, I understood exactly why Helm exists. Copy-pasting YAML and hand-editing values is fine for a weekend project; it’s a liability for anything real. Helm turns Kubernetes manifests into templated, versioned, parameterized packages, and this guide covers it from first principles through production patterns.

What Helm Actually Is

Helm is often described as “the package manager for Kubernetes,” which is accurate but understates what it does. A chart is a directory of templated YAML manifests plus metadata. A release is a specific instantiation of a chart with a specific set of values, tracked by Helm as a distinct object in the cluster. Helm 3 (the current major version) stores release state as Kubernetes Secrets in the target namespace — there’s no separate server-side component (Tiller) like there was in Helm 2.

Kubernetes Architecture Context

Helm doesn’t replace the Kubernetes API — it’s a client that renders templates into standard manifests and applies them via the same API server every kubectl apply goes through. The control plane (API server, etcd, scheduler, controller manager) has no special awareness of Helm; from Kubernetes’ perspective, a Helm-deployed Deployment is indistinguishable from a hand-applied one, except for the ownership annotations Helm adds for tracking.

Installing Helm

curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
chmod +x get_helm.sh
./get_helm.sh

helm version

Output:

version.BuildInfo{Version:"v3.15.0", GitCommit:"...", GoVersion:"go1.22.1"}

Anatomy of a Chart

helm create myapp

This scaffolds:

myapp/
  Chart.yaml
  values.yaml
  charts/
  templates/
    deployment.yaml
    service.yaml
    ingress.yaml
    _helpers.tpl
    NOTES.txt

Chart.yaml holds metadata:

apiVersion: v2
name: myapp
description: A Helm chart for myapp
type: application
version: 0.1.0
appVersion: "1.0.0"

values.yaml holds the default configuration:

replicaCount: 2

image:
  repository: registry.example.com/myapp
  tag: "1.0.0"
  pullPolicy: IfNotPresent

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

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false

Templating

Templates in templates/deployment.yaml reference values with Go templating:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-{{ .Chart.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Chart.Name }}
  template:
    metadata:
      labels:
        app: {{ .Chart.Name }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          ports:
            - containerPort: 8080

Render without installing, to sanity-check output:

helm template myapp ./myapp

Installing and Upgrading Releases

helm install myapp-prod ./myapp \
  --namespace production --create-namespace \
  --values values-production.yaml

helm list -n production

Output:

NAME          NAMESPACE   REVISION  STATUS    CHART        APP VERSION
myapp-prod    production  1         deployed  myapp-0.1.0  1.0.0

Upgrading with a new image tag:

helm upgrade myapp-prod ./myapp \
  --namespace production \
  --set image.tag=1.1.0

Roll back if the upgrade misbehaves:

helm rollback myapp-prod 1 -n production

Values Files per Environment

# values-production.yaml
replicaCount: 6
image:
  tag: "1.1.0"
resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: "1"
    memory: 1Gi
ingress:
  enabled: true
  host: app.example.com
helm upgrade --install myapp-prod ./myapp -f values-production.yaml -n production

This is the core Helm workflow: one chart, many values files, one command per environment.

Dependencies and Subcharts

Charts can depend on other charts — a common pattern is bundling Redis or Postgres as a dependency:

# Chart.yaml
dependencies:
  - name: redis
    version: "18.x.x"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled
helm dependency update ./myapp

RBAC and ServiceAccounts via Helm

A chart commonly templates its own ServiceAccount and RBAC bindings:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ .Chart.Name }}-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {{ .Chart.Name }}-role
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]

Helm in CI/CD

Helm slots directly into a pipeline stage:

helm upgrade --install myapp ./chart \
  --namespace production \
  --set image.tag=$BUILD_NUMBER \
  --atomic --timeout 5m

--atomic automatically rolls back on failure, and --timeout prevents a pipeline from hanging indefinitely on a broken rollout — both are worth using by default in any automated deploy stage.

Monitoring Helm Releases

helm status myapp-prod -n production
helm history myapp-prod -n production
helm get values myapp-prod -n production

High Availability and Storage Considerations

Helm itself has no runtime component to make HA — it’s a client tool. The HA concerns live in the charts you deploy: setting replicaCount appropriately, using PodDisruptionBudget templates, and making sure any StatefulSet-based dependency (like a database chart) uses a proper StorageClass with replication rather than a single local volume.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ .Chart.Name }}-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: {{ .Chart.Name }}

Helm Hooks for Lifecycle Management

Real deployments often need something to happen before or after an install/upgrade — a database migration, a cache warm-up, a smoke test. Helm hooks handle this without leaving the declarative model:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-db-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "0"
    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./migrate.sh"]
      restartPolicy: Never

hook-weight controls ordering when multiple hooks exist at the same lifecycle point — lower numbers run first. hook-delete-policy determines cleanup: hook-succeeded removes the Job once it finishes successfully, keeping the namespace from filling up with completed migration Jobs across every upgrade. Without this, a team running weekly deploys ends up with dozens of stale Job objects that add noise to every kubectl get jobs output.

Testing Charts Before They Reach Production

helm lint catches structural problems early:

helm lint ./myapp
==> Linting ./myapp
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed

For a closer approximation of what will actually happen in the cluster, combine --dry-run with --debug:

helm install myapp-test ./myapp --dry-run --debug -n staging

This renders every template exactly as it would be applied, without touching the cluster — the single most useful habit for catching a broken {{ }} expression before it becomes a failed production upgrade. Helm also supports a dedicated test framework via the helm.sh/hook: test annotation, letting a chart ship its own post-install verification:

apiVersion: v1
kind: Pod
metadata:
  name: {{ .Release.Name }}-test-connection
  annotations:
    "helm.sh/hook": test
spec:
  containers:
    - name: wget
      image: busybox
      command: ['wget']
      args: ['{{ .Release.Name }}-{{ .Chart.Name }}:{{ .Values.service.port }}']
  restartPolicy: Never
helm test myapp-prod -n production

Managing Secrets Alongside Charts

Helm’s values.yaml is not an appropriate place for actual secret material, since it’s typically committed to version control in full. Two common patterns fill the gap without breaking the “one command deploys everything” workflow:

  • Helm Secrets / SOPS — values files encrypted at rest, decrypted transparently at deploy time by a Helm plugin.
  • External Secrets Operator — the chart references a Kubernetes Secret that’s populated separately from an external vault (AWS Secrets Manager, HashiCorp Vault, etc.), so the chart itself never carries secret material at all:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: {{ .Chart.Name }}-db-creds
spec:
  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore
  target:
    name: {{ .Chart.Name }}-db-creds
  data:
    - secretKey: password
      remoteRef:
        key: prod/myapp/db-password

Repository Management and Distribution

For internal charts shared across teams, hosting a private chart repository (via OCI registries, which Helm 3 supports natively, or a dedicated tool like ChartMuseum) keeps versioned charts discoverable the same way container images are:

helm package ./myapp
helm push myapp-0.1.0.tgz oci://registry.example.com/charts

helm install myapp oci://registry.example.com/charts/myapp --version 0.1.0

OCI-based distribution has become the more common approach recently, since it reuses the same registry infrastructure teams already run for container images rather than standing up a separate chart-hosting service.

Library Charts for Shared Templates

When multiple charts across a team share common boilerplate — the same labels, the same probe configuration structure — a library chart lets that logic live in one place rather than being copy-pasted into every application chart’s templates directory:

# common/Chart.yaml
apiVersion: v2
name: common
type: library
version: 0.1.0
# common/templates/_deployment.tpl
{{- define "common.deployment" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  labels:
    app.kubernetes.io/managed-by: {{ .Release.Service }}
    app.kubernetes.io/name: {{ .Chart.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
{{- end -}}

Consuming charts declare the library as a dependency and call the shared template rather than redefining it:

dependencies:
  - name: common
    version: "0.1.0"
    repository: "file://../common"
{{ include "common.deployment" . }}

This is the same motivation as shared CI pipeline libraries — one canonical definition of “what a standard Deployment looks like at this organization,” updated in a single place rather than drifting slowly out of sync across a dozen separate application repositories.

Validating values.yaml with a JSON Schema

For charts consumed by multiple teams (or exposed as an internal self-service platform offering), a values.schema.json file lets Helm reject invalid configuration before it ever reaches the API server, rather than surfacing as a confusing downstream error:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image", "replicaCount"],
  "properties": {
    "replicaCount": {
      "type": "integer",
      "minimum": 1,
      "maximum": 20
    },
    "image": {
      "type": "object",
      "required": ["repository", "tag"],
      "properties": {
        "repository": { "type": "string" },
        "tag": { "type": "string" }
      }
    }
  }
}
helm install myapp ./myapp --set replicaCount=0
Error: values don't meet the specifications of the schema(s) in the following chart(s):
myapp:
- replicaCount: Must be greater than or equal to 1

This kind of upfront validation is particularly valuable once a chart moves from “something one team maintains for itself” to “something other teams are expected to configure and deploy independently” — the schema becomes a form of self-documenting contract, catching mistakes at the point someone makes them rather than several steps downstream.

Common Mistakes

  • Hardcoding secrets directly into values.yaml and committing it — use --set-file, Sealed Secrets, or an external secrets operator instead.
  • Not pinning chart dependency versions, so a helm dependency update silently pulls in a breaking change.
  • Skipping helm template / --dry-run before applying to production.
  • Treating helm upgrade as always safe without --atomic, then discovering a half-applied rollout at 2 a.m.

Summary

Helm doesn’t add any new capability Kubernetes doesn’t already have — everything it produces is standard manifests. What it adds is repeatability: one chart, versioned and parameterized, deployed consistently to as many environments as you need, with rollback built in. Once a team has more than one environment or more than one microservice, the templating and release-tracking Helm provides stops being a convenience and starts being close to a necessity.

References

  • Helm documentation: https://helm.sh/docs/
  • Helm chart best practices: https://helm.sh/docs/chart_best_practices/
  • Artifact Hub (chart registry): https://artifacthub.io/
  • Kubernetes documentation: https://kubernetes.io/docs/home/
Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Pod Security Policies in Kubernetes

How to Set Up Pod Security Policies in Kubernetes

Next Post
How to Set Up CI/CD with Jenkins and Kubernetes

How to Set Up CI/CD with Jenkins and Kubernetes

Related Posts