Your First Helm Chart

Create, install, and manage your very first Helm chart. Deploy an application to Kubernetes using Helm.

6 min read

Your First Helm Chart

In the previous tutorial, we installed Helm and even deployed an nginx chart from the Bitnami repo. That's cool, but using someone else's chart is like eating at a restaurant — let's learn to cook.

Time to build your own chart from scratch.

Creating a Chart

Helm has a built-in command to scaffold a new chart:

helm create my-first-chart

This creates a directory with everything you need:

my-first-chart/
├── Chart.yaml
├── values.yaml
├── charts/
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── serviceaccount.yaml
│   ├── hpa.yaml
│   ├── ingress.yaml
│   ├── NOTES.txt
│   ├── _helpers.tpl
│   └── tests/
│       └── test-connection.yaml
└── .helmignore

"That's a lot of files for a 'first' chart."

Yeah, helm create gives you the full production template. It's great as a reference, but it can be overwhelming. Let's strip it down and build something simpler from scratch so you understand every piece.

Building a Chart from Scratch

Delete the generated chart and start fresh:

rm -rf my-first-chart
mkdir -p my-first-chart/templates

Step 1: Chart.yaml

Every chart needs a Chart.yaml — it's the chart's identity card.

# my-first-chart/Chart.yaml
apiVersion: v2
name: my-first-chart
description: My very first Helm chart
type: application
version: 0.1.0
appVersion: "1.0.0"

Let's break this down:

  • apiVersion: v2 — Helm 3 charts use v2. Helm 2 charts used v1. Always use v2.
  • name — The chart name. Must match the directory name.
  • description — What this chart does. Shows up in search results.
  • type — Either application (deployable) or library (reusable helpers, not deployable).
  • version — The chart version. Follows SemVer. Bump this when you change the chart.
  • appVersion — The version of the app being deployed. This is informational — it doesn't affect Helm behavior.

"What's the difference between version and appVersion?"

version is the chart's version (the packaging). appVersion is the application's version (the thing inside). You might update the chart (adding a new value, fixing a template) without changing the app version, and vice versa.

Step 2: A Simple Deployment Template

Create a Deployment template:

# my-first-chart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-app
  labels:
    app: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - containerPort: {{ .Values.containerPort }}

See those {{ }} things? That's Go template syntax. Helm uses it to inject values into your YAML at deploy time. Don't worry about mastering it now — we'll deep-dive into templating in upcoming tutorials.

For now, just know:

  • {{ .Release.Name }} — The name you give when running helm install
  • {{ .Values.something }} — Values from values.yaml (or overridden at install time)
  • {{ .Chart.Name }} — The chart name from Chart.yaml

Step 3: A Simple Service Template

# my-first-chart/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-svc
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.containerPort }}
  selector:
    app: {{ .Release.Name }}

Step 4: Default Values

The values.yaml file defines defaults for all those {{ .Values.xxx }} references:

# my-first-chart/values.yaml
replicaCount: 1

image:
  repository: nginx
  tag: "1.25"

containerPort: 80

service:
  type: ClusterIP
  port: 80

This is the beauty of Helm — sensible defaults that work out of the box, but everything is configurable.

Step 5: NOTES.txt (Optional but Nice)

This file prints helpful info after install:

# my-first-chart/templates/NOTES.txt
🎉 {{ .Release.Name }} has been deployed!

To access your application:
  kubectl port-forward svc/{{ .Release.Name }}-svc {{ .Values.service.port }}:{{ .Values.service.port }}

Then open http://localhost:{{ .Values.service.port }} in your browser.

Your Chart Structure

Here's what you should have now:

my-first-chart/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    └── NOTES.txt

Clean and simple. No bloat.

Installing Your Chart

Let's deploy it:

helm install my-release ./my-first-chart

You should see your NOTES.txt output:

🎉 my-release has been deployed!

To access your application:
  kubectl port-forward svc/my-release-svc 80:80

Then open http://localhost:80 in your browser.

Check what got created:

kubectl get all -l app=my-release
NAME                                  READY   STATUS    RESTARTS   AGE
pod/my-release-app-6d4f5b8c9d-xxxxx   1/1     Running   0          10s

NAME                     TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/my-release-svc   ClusterIP   10.96.45.123   <none>        80/TCP    10s

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/my-release-app   1/1     1            1           10s

Boom! One command, and your Deployment + Service are running.

Overriding Values

Now let's see the real power. Deploy the same chart with different settings:

Using --set

helm install my-release-v2 ./my-first-chart \
  --set replicaCount=3 \
  --set image.tag="1.24" \
  --set service.type=NodePort

Using a Values File

For more complex overrides, create a separate file:

# production-values.yaml
replicaCount: 5

image:
  repository: nginx
  tag: "1.25-alpine"

service:
  type: LoadBalancer
  port: 80
helm install my-release-prod ./my-first-chart -f production-values.yaml

"Can I combine --set and -f?"

Yes! --set values override -f file values, which override values.yaml defaults. The order of precedence:

  1. values.yaml (lowest priority — defaults)
  2. -f custom-values.yaml (overrides defaults)
  3. --set key=value (highest priority — overrides everything)

Previewing Before Installing

Not sure what your chart will produce? Use --dry-run:

helm install my-release ./my-first-chart --dry-run --debug

This renders all templates and shows you the final YAML without sending anything to the cluster. Incredibly useful for debugging.

You can also use helm template for the same purpose:

helm template my-release ./my-first-chart

The difference? helm template is purely local — it doesn't even need a cluster connection. --dry-run validates against the actual cluster.

Upgrading a Release

Made changes to your chart? Upgrade the release:

helm upgrade my-release ./my-first-chart --set replicaCount=3

Helm compares the new rendered templates against the current state and applies only the differences. Smart.

Checking Release Status

# List all releases
helm list

# Get details about a specific release
helm status my-release

# See what values are in use
helm get values my-release

# See the rendered manifests
helm get manifest my-release

Cleaning Up

helm uninstall my-release
helm uninstall my-release-v2

All resources created by the releases are deleted. Clean and tidy.

What's Next?

You just built and deployed your first Helm chart from scratch! Here's what you learned:

  • Creating a chart with Chart.yaml, values.yaml, and templates
  • Go template basics ({{ .Values.xxx }}, {{ .Release.Name }})
  • Installing, upgrading, and uninstalling releases
  • Overriding values with --set and -f
  • Previewing templates with --dry-run and helm template

In the next tutorial, we'll take a deeper look at chart structure — understanding every file and directory, and the conventions that make charts maintainable. Let's go!