Skip to content
Cloud & Infra Intermediate Tutorial

Package and Deploy Your First Helm Chart to Kubernetes

Turn a web app and a Redis cache into one reusable Helm chart, with environment-specific values, a real subchart dependency, and an upgrade/rollback flow you actually run against a cluster.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Jul 17, 2026 · 9 min read
Package and Deploy Your First Helm Chart to Kubernetes

What you'll build

You'll package a small web app (podinfo) plus a Redis cache into one Helm chart with environment-specific values files, a real subchart dependency, and a working upgrade/rollback cycle against a live Kubernetes cluster.

Prerequisites

  • A cluster you can kubectl into (kind, minikube, EKS, GKE, AKS, whatever). This tutorial uses kind v0.23+ for a disposable local cluster.
  • kubectl v1.28+
  • Helm v3.14+. Run helm version --short, expect something like v3.14.4+g<sha>. Helm 3 has no Tiller, nothing running server-side.
  • Docker or Podman running, if you're using kind.

1. Spin up a cluster

Skip this if you already have one.

brew install kind   # or: go install sigs.k8s.io/kind@v0.23.0
kind create cluster --name helm-tutorial
kubectl cluster-info --context kind-helm-tutorial

2. Scaffold the chart

helm create hello-chart
cd hello-chart
rm -rf templates/hpa.yaml templates/ingress.yaml templates/serviceaccount.yaml templates/tests

helm create gives you a working Deployment, Service, and a _helpers.tpl full of naming and label helpers (hello-chart.fullname, hello-chart.labels, hello-chart.selectorLabels). Leave _helpers.tpl alone, you'll reuse those helpers. The templates we deleted aren't wrong, this tutorial just doesn't need them.

3. Template the web service

We're deploying podinfo, a small Go app built specifically for demoing Kubernetes tooling. It listens on 9898, exposes /healthz and /readyz, and echoes a PODINFO_UI_MESSAGE env var back on its home page, handy for proving an upgrade actually changed something.

Replace templates/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "hello-chart.fullname" . }}
  labels:
    {{- include "hello-chart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "hello-chart.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "hello-chart.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          ports:
            - containerPort: {{ .Values.service.port }}
          env:
            - name: PODINFO_UI_MESSAGE
              value: {{ .Values.message | quote }}
          readinessProbe:
            httpGet:
              path: /readyz
              port: {{ .Values.service.port }}
          livenessProbe:
            httpGet:
              path: /healthz
              port: {{ .Values.service.port }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

Replace templates/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "hello-chart.fullname" . }}
  labels:
    {{- include "hello-chart.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.port }}
      name: http
  selector:
    {{- include "hello-chart.selectorLabels" . | nindent 4 }}

4. Add Redis as a real dependency

Bitnami has been shifting its charts off the classic charts.bitnami.com/bitnami index toward OCI-first distribution, so pull straight from the OCI registry. It's the actively maintained path, and Helm 3.8+ supports OCI natively, no helm repo add, no experimental flag needed.

Check what version is current:

helm show chart oci://registry-1.docker.io/bitnamicharts/redis | head -5

Pin whatever version comes back in Chart.yaml:

apiVersion: v2
name: hello-chart
description: Web app + Redis cache packaged as one chart
type: application
version: 0.1.0
appVersion: "6.7.0"
dependencies:
  - name: redis
    version: "20.3.6"   # use whatever `helm show chart` returned
    repository: "oci://registry-1.docker.io/bitnamicharts"
    condition: redis.enabled

The repository field points at the registry path, not the full chart URL, Helm appends /redis:20.3.6 itself when it resolves the dependency.

Pull it down:

helm dependency update

This writes Chart.lock and drops a .tgz into charts/. Commit both, the lock file pins the exact resolved version so future helm dependency update runs are reproducible. Some teams .gitignore the .tgz and run helm dependency build in CI instead, which rebuilds charts/ from Chart.lock.

condition: redis.enabled means the subchart only renders if redis.enabled is true in your values, no edits to Chart.yaml needed to toggle it.

5. Layer values files

Base values.yaml:

replicaCount: 1
image:
  repository: ghcr.io/stefanprodan/podinfo
  tag: "6.7.0"
  pullPolicy: IfNotPresent
service:
  type: ClusterIP
  port: 9898
message: "hello from Helm"
resources:
  requests: { cpu: 50m, memory: 32Mi }
  limits: { cpu: 200m, memory: 128Mi }
redis:
  enabled: true
  architecture: standalone
  auth:
    enabled: false

values-dev.yaml (merged on top of values.yaml):

message: "hello from dev"

values-prod.yaml:

replicaCount: 3
message: "hello from prod"
service:
  type: LoadBalancer
redis:
  auth:
    enabled: true

One chart, multiple environments, no forked YAML.

6. Lint and render before touching the cluster

helm lint . -f values-dev.yaml
helm template hello-dev . -f values-dev.yaml | less

helm lint catches structural mistakes. helm template shows the exact manifests Kubernetes will see, zero cluster contact. Always do this before installing anything you don't fully trust yet.

7. Install the release

helm install hello-dev . -f values-dev.yaml -n demo --create-namespace

--create-namespace creates demo if it doesn't already exist. hello-dev is the release name, not the chart name, one chart, many releases, each tracked independently.

8. Upgrade it

Change something and push it:

helm upgrade hello-dev . -f values-dev.yaml \
  --set message="hello from Helm v2" -n demo
helm history hello-dev -n demo

Helm diffs against the last release and only touches what changed. You should see two revisions, 1 marked superseded and 2 marked deployed.

9. Roll back

Say v2 broke something:

helm rollback hello-dev 1 -n demo
helm history hello-dev -n demo

This creates a new revision (3) whose manifests match revision 1, it doesn't delete history. That's deliberate, Helm never rewrites the past.

Verify it works

kubectl get pods -n demo
kubectl get svc -n demo

You should see a podinfo pod and a redis pod (something like hello-dev-redis-master-0), both Running. Port-forward the web service (confirm the exact name from kubectl get svc):

kubectl port-forward -n demo svc/hello-dev-hello-chart 9898:9898
curl -s localhost:9898 | grep message

You should see whatever string you set most recently, via --set message=... or the rollback target. That confirms the template rendered correctly, the env var reached the container, and the release history reflects what's actually running.

Troubleshooting

helm dependency update fails on the Redis OCI pull - usually a typo in the repository field (it should be the registry path, not .../bitnamicharts/redis) or a version that doesn't exist at that path. Re-run helm show chart oci://registry-1.docker.io/bitnamicharts/redis to see what's actually published before pinning a version.

Pods stuck in ImagePullBackOff - almost always a bad tag. Check .Values.image.tag against real tags on the registry. Bitnami has also been tightening which image tags stay free on Docker Hub, so for Redis specifically, run kubectl describe pod <name> -n demo to see the exact pull error before assuming it's your chart's fault.

UPGRADE FAILED: another operation (install/upgrade/rollback) is in progress - a previous operation got interrupted (Ctrl-C mid-upgrade, a network blip) and Helm's release lock is stuck. Any new operation on that release hits the same lock. Run helm history hello-dev -n demo, find the stuck pending-upgrade revision, confirm no other Helm process is running against that release, and retry once it clears.

YAML render errors pointing at nindent - usually a mismatch between the number passed to nindent and where the block sits in the surrounding YAML. Run helm template locally and read the actual rendered output, the error line numbers refer to the rendered file, not your source template.

Next steps

  • Push the packaged chart (helm package .) to an OCI registry (helm push hello-chart-0.1.0.tgz oci://<registry>/charts), the current recommended distribution path in Helm 3.8+.
  • Add chart-testing (ct lint, ct install) to CI so broken templates never reach a cluster.
  • Look at Helmfile or a GitOps controller (Argo CD, Flux) once managing more than a couple of releases by hand gets tedious.
  • For real secrets (API keys, DB passwords), don't put them in values files in plaintext, use Helm with SOPS, Sealed Secrets, or an external secrets operator.
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 1

Join the discussion

Sign in or create an account to comment and vote.

Yuki Tanaka @distsys_yuki · 18 hours ago

need to try helm 3 with our crdt cluster

Related Reading