Introduction
When you start working with Kubernetes, writing YAML files for Deployments, Services, ConfigMaps, and other resources is a good way to learn how applications are deployed. However, as your application grows or you need to deploy it across multiple environments, managing all these files manually becomes increasingly difficult. You will run into problems like:
- You have many YAML files that look almost the same across environments
- Changing a simple value (image tag, number of replicas, database password, etc.) forces you to edit multiple files
- There is no clean way to version an entire application or roll back to a previous working state
- Sharing the same application with teammates becomes messy
Helm was created to solve exactly these problems.
Helm is the package manager for Kubernetes. It allows you to package all the Kubernetes resources that make up an application into a single reusable unit called a Chart. You can then install, upgrade, configure, and roll back that application with simple commands.
In this post we will:
- Understand the core ideas behind Helm
- Turn a realistic demo application into a Helm chart
- Learn how to configure, install, upgrade, and roll back releases
- See practical commands and best practices
What is Helm?
Helm packages Kubernetes manifests (Deployments, Services, ConfigMaps, Secrets, etc.) into a Chart.
When you install a Chart, Helm creates a Release — a running instance of that Chart in your cluster.
You can have multiple Releases of the same Chart (for example one for development and one for production) with different configuration.
Main benefits of Helm:
- One command installs an entire application
-
Centralized configuration through a
values.yamlfile - Easy upgrades and rollbacks
- Reusability — the same Chart can be used across environments and teams
- Versioning — every change to the Chart can be versioned
Think of Helm as apt, yum, or brew, but for Kubernetes applications.
Core Concepts Explained
| Concept | What it means | Real-world analogy |
|---|---|---|
| Chart | A folder that contains all the Kubernetes YAML templates + default values | A software package (.deb / .rpm) |
| Release | A running instance of a Chart in a cluster | An installed application |
| Values | Configuration that controls how the templates are rendered | Settings / preferences |
| Template | A Kubernetes YAML file that contains placeholders ({{ .Values.xxx }}) |
A form with blank fields |
| Repository | A place where Charts are stored and shared | App Store / Docker Hub |
Prerequisites
# Start Minikube
minikube start
# Enable the addons we need for Ingress and HPA
minikube addons enable ingress
minikube addons enable metrics-server
# Verify the addons are running
kubectl get pods -n ingress-nginx
kubectl get pods -n kube-system | grep metrics-server
Also install Helm if you haven’t already:
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Check the version
helm version
1. Create the Chart
helm create demo-app
cd demo-app
Your chart structure should look like this:
demo-app/
├── Chart.yaml
├── values.yaml
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── serviceaccount.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ ├── httproute.yaml
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ └── tests/
│ └── test-connection.yaml
└── charts/
2. Configure values.yaml
This is the heart of your chart. Users will mainly edit this file instead of touching the templates. Replace the entire content of values.yaml with the following:
# Default values for demo-app
# This file controls almost everything in the chart.
replicaCount: 2
image:
repository: nginx
tag: "1.25"
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: "demo-app" # Forces clean resource names
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext: {}
service:
type: ClusterIP
port: 80
ingress:
enabled: true
className: "nginx"
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
hosts:
- host: demo.local
paths:
- path: /
pathType: Prefix
tls: []
# Horizontal Pod Autoscaler
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
# CPU utilization is measured as a percentage of each pod's CPU request.
targetCPUUtilizationPercentage: 50
# Gateway API HTTPRoute
httpRoute:
enabled: false
resources:
requests:
# HPA utilization targets need CPU requests to calculate utilization.
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
# Application specific configuration
app:
name: "My Demo App"
version: "1.0.0"
environment: "development"
logLevel: "info"
persistence:
size: 1Gi
accessMode: ReadWriteOnce
secret:
dbPassword: "supersecretpassword"
nodeSelector: {}
tolerations: []
affinity: {}
3. Understanding Every Template File
3.1 templates/serviceaccount.yaml
What this file does:
Creates a Kubernetes ServiceAccount that the application pods will use.
Why it is useful:
Even simple applications benefit from having their own identity. When your application needs special permissions, you can grant them to this ServiceAccount using Role-Based Access Control.
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "demo-app.serviceAccountName" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
{{- end }}
3.2 templates/configmap.yaml
Create a file configmap.yaml inside the templates directory.
What this file does:
Stores non-sensitive configuration. In our case, stores a custom HTML page that nginx will serve.
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "demo-app.fullname" . }}-config
labels:
{{- include "demo-app.labels" . | nindent 4 }}
data:
# The Deployment mounts this rendered page into nginx's document root.
index.html: |
<!DOCTYPE html>
<html>
<head>
<title>{{ .Values.app.name }}</title>
<style>
body { font-family: Arial, sans-serif; max-width: 700px; margin: 50px auto; padding: 20px; }
h1 { color: #326ce5; }
.info { background: #f5f5f5; padding: 15px; border-radius: 8px; }
</style>
</head>
<body>
<h1>Hello from {{ .Values.app.name }}</h1>
<div class="info">
<p><strong>Version:</strong> {{ .Values.app.version }}</p>
<p><strong>Environment:</strong> {{ .Values.app.environment }}</p>
<p><strong>Log Level:</strong> {{ .Values.app.logLevel }}</p>
<p>This page is served by a complete Helm chart (Ingress + HPA + HTTPRoute).</p>
</div>
</body>
</html>
3.3 templates/secret.yaml
Create a file secret.yaml inside the templates directory.
What this file does:
Stores sensitive data (in this case a database password).
What is happening:
This template reads the plain-text password defined in values.yaml (under secret.dbPassword), base64-encodes it using Helm's b64enc function, and stores the result in a Kubernetes Secret. The Deployment will later use this secret as an environment variable.
apiVersion: v1
kind: Secret
metadata:
name: {{ include "demo-app.fullname" . }}-secret
labels:
{{- include "demo-app.labels" . | nindent 4 }}
type: Opaque
data:
db-password: {{ .Values.secret.dbPassword | b64enc | quote }}
3.4 templates/pvc.yaml
Create a file pvc.yaml inside the templates directory.
What this file does:
Requests persistent storage from the cluster.
What is happening:
A PersistentVolumeClaim is created. Kubernetes binds it to a compatible PersistentVolume, often provisioned dynamically by the cluster's default StorageClass. Without a matching volume or StorageClass, the claim remains Pending. The Deployment will later mount this volume at /data.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "demo-app.fullname" . }}-pvc
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size }}
3.5 templates/deployment.yaml
What this file does:
This is the most important file. It defines the pods that actually run your application.
What is happening:
- Creates the desired number of replicas (or lets HPA control it).
- Uses the ServiceAccount we created.
- Uses ConfigMap and Secret we created.
- Mounts the custom HTML page and the persistent volume.
- Sets CPU and memory requests/limits (required for HPA to work).
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
# Let the HPA manage replicas when autoscaling is enabled.
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "demo-app.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "demo-app.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "demo-app.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: nginx
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
env:
- name: DB_PASSWORD
valueFrom:
# Reference the Secret instead of embedding its value in the pod spec.
secretKeyRef:
name: {{ include "demo-app.fullname" . }}-secret
key: db-password
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
- name: data
mountPath: /data
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: html
configMap:
# Mount only the generated page into nginx's document root.
name: {{ include "demo-app.fullname" . }}-config
items:
- key: index.html
path: index.html
- name: data
persistentVolumeClaim:
# Reuse the claim created by templates/pvc.yaml.
claimName: {{ include "demo-app.fullname" . }}-pvc
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
3.6 templates/service.yaml
What this file does:
Creates a stable network endpoint for the pods.
What is happening:
A ClusterIP Service is created. Ingress and HTTPRoute will send traffic to this Service. The selector matches the labels used by the Deployment.
apiVersion: v1
kind: Service
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "demo-app.selectorLabels" . | nindent 4 }}
3.7 templates/ingress.yaml
What this file does:
Exposes the application outside the cluster using the classic Ingress resource.
What is happening:
- Only created when
ingress.enabledistrue. - Uses the nginx Ingress controller (the one we enabled with Minikube).
- Routes traffic from
demo.localto our Service.
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
# The Ingress controller interprets these annotations.
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
# Match requests for this host and route them to the chart's Service.
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- with .pathType }}
pathType: {{ . }}
{{- end }}
backend:
service:
name: {{ include "demo-app.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
3.8 templates/hpa.yaml
What this file does:
Automatically scales the number of pods up and down based on CPU usage.
What is happening:
- Only created when
autoscaling.enabledistrue. - Watches the CPU utilization of the pods.
- Scales between
minReplicasandmaxReplicas.
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "demo-app.fullname" . }}
labels:
{{- include "demo-app.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "demo-app.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
# Percentage of the CPU request specified on each pod.
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
3.9 templates/NOTES.txt
What this file does:
Prints helpful information in the terminal after you run helm install or helm upgrade.
You can customize it. A simple useful version is:
========================================================
Thank you for installing {{ .Chart.Name }}!
Your release is named: {{ .Release.Name }}
Namespace: {{ .Release.Namespace }}
Application URL:
{{- if .Values.ingress.enabled }}
http://{{ (index .Values.ingress.hosts 0).host }}
{{- else }}
kubectl port-forward svc/{{ include "demo-app.fullname" . }} 8080:80 -n {{ .Release.Namespace }}
Then open http://localhost:8080
{{- end }}
To see all resources created by this release:
kubectl get all,cm,secret,pvc,ingress,hpa -n {{ .Release.Namespace }}
========================================================
4. Install the Chart
Now that we have created our own working helm Chart, let's install it.
# Install the chart (--create-namespace creates "demo" if it doesn't exist yet)
helm install demo-app . -n demo --create-namespace
Verify that everything was created:
kubectl get all,cm,secret,pvc,ingress,hpa -n demo
You should see:
- Deployment + ReplicaSet + Pods
- Service
- ConfigMap + Secret + PVC
- ServiceAccount
- Ingress
- HorizontalPodAutoscaler
5. Access the Application via Ingress
Add an entry to your /etc/hosts file so that demo.local points to Minikube:
echo "$(minikube ip) demo.local" | sudo tee -a /etc/hosts
Now open your browser:
http://demo.local
You should see the custom page served by the application.
(Alternatively you can use port-forward):
kubectl port-forward svc/demo-app 8080:80 -n demo
And access using http://localhost:8080
6. Test Autoscaling (HPA)
Generate some CPU load so the Horizontal Pod Autoscaler can react:
# Start a temporary load-generator pod
kubectl run -i --tty load-generator --rm --image=busybox --restart=Never -n demo -- /bin/sh
Inside the pod run:
while true; do wget -q -O- http://demo-app; done
In another terminal watch the HPA and the pods:
kubectl get hpa -n demo --watch
kubectl get pods -n demo --watch
When CPU usage rises, you will see the number of replicas increase.
7. Upgrade and Rollback
Example 1: Change environment + adjust HPA
# Change the environment and set a new minimum number of replicas
helm upgrade demo-app . -n demo \
--set app.environment=staging \
--set autoscaling.minReplicas=4 \
--set autoscaling.maxReplicas=10
Example 2: Temporarily disable HPA and set a fixed number of replicas
helm upgrade demo-app . -n demo \
--set autoscaling.enabled=false \
--set replicaCount=4 \
--set app.environment=staging
Example 3: Edit values.yaml and upgrade
You can also change any values directly in values.yaml and then run:
helm upgrade demo-app . -n demo
What the helm upgrade command does:
Helm recalculates all the manifests with the new values and performs a rolling update of the Deployment (and updates the HPA, Ingress, ConfigMap, etc.).
Rollback
# Roll back to revision 1
helm rollback demo-app 1 -n demo
# Roll back to the previous revision without specifying a number
helm rollback demo-app -n demo
A rollback restores the exact configuration from that revision and performs a rolling update, just like an upgrade. Helm doesn't delete history on rollback; it appends a new revision recording it, so you can always roll forward again later.
View history
# See all revisions of this release
helm history demo-app -n demo
This lists every revision with its status and description, so you know which revision number to pass to helm rollback.
8. Useful Helm Commands
# List releases
helm list -n demo
# See all values currently used by the release
helm get values demo-app -n demo --all
# See the final rendered Kubernetes manifests
helm get manifest demo-app -n demo
# Upgrade after changing values.yaml
helm upgrade demo-app . -n demo
# View release history
helm history demo-app -n demo
# Rollback to a previous revision
helm rollback demo-app 1 -n demo
# Run the built-in test
helm test demo-app -n demo
# Uninstall the release
helm uninstall demo-app -n demo
Best Practices
- Keep all configurable values in
values.yaml. - Use
fullnameOverridewhen you want clean resource names. - Always set resource requests when you enable HPA.
- Never commit real production secrets to Git.
- Use
helm templateor--dry-runwhile developing.
Top comments (0)