Helm charts are the secret sauce that makes packaging and deploying microservices on Kubernetes feel less like a chaotic free-for-all and more like a well-oiled machine.
Imagine you’ve got a microservice, say, a user authentication service. It needs a deployment, a service to expose it, maybe a config map for its settings, and perhaps a persistent volume claim if it stores anything. Manually creating all these Kubernetes YAML files for each service, and then trying to keep them in sync as your application evolves, is a recipe for disaster. Helm charts bundle all these Kubernetes resources into a single, versionable package.
Here’s a simple Helm chart for our hypothetical auth-service:
# Create a new chart named auth-service
helm create auth-service
This command generates a directory structure like this:
auth-service/
├── charts/
├── charts.lock
├── Chart.yaml
├── templates/
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ └── serviceaccount.yaml
├── .helmignore
└── values.yaml
The templates/ directory is where the magic happens. It contains Go templating language files that are rendered into actual Kubernetes YAML using data from values.yaml and any passed-in configuration.
Let’s look at templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "auth-service.fullname" . }}
labels:
{{- include "auth-service.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "auth-service.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "auth-service.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
protocol: TCP
livenessProbe:
httpGet:
path: /healthz
port: http
readinessProbe:
httpGet:
path: /readyz
port: http
resources:
{{- toYaml .Values.resources | nindent 10 }}
And here’s the corresponding values.yaml:
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
tag: "1.21.6" # Example version
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
securityContext: {}
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
targetMemoryUtilizationPercentage: 80
nodeSelector: {}
tolerations: []
affinity: {}
When you deploy this chart, Helm takes values.yaml and the templates, renders them, and applies the resulting YAML to your Kubernetes cluster. For instance, if you want to deploy a specific version of your auth-service image, you’d override image.tag:
helm install my-auth-release ./auth-service --set image.tag=v1.2.0
This single command creates a Deployment with 1 replica, using the my-docker-registry/auth-service:v1.2.0 image, and a ClusterIP service exposing port 80.
The power comes from templating. You can define complex logic, conditional statements, and loops within your templates, making charts highly dynamic and reusable. For example, you can conditionally enable an Ingress based on a value:
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "auth-service.fullname" . }}
labels:
{{- include "auth-service.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.hosts }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "auth-service.fullname" . }}
port:
number: 80
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
{{- end }}
This allows users of your chart to simply set ingress.enabled: true in their values.yaml to spin up an ingress resource, without needing to modify the chart’s templates.
One of the most underrated aspects of Helm is its ability to manage releases as discrete, versioned entities. When you helm install or helm upgrade, Helm records the exact chart version and values used. This means you can roll back to a previous state with helm rollback <release-name> <revision-number> if something goes wrong, and you have a clear audit trail of what was deployed.
Beyond packaging, Helm provides a robust ecosystem for sharing charts through chart repositories. Companies often maintain internal repositories for their proprietary services, and public repositories like Artifact Hub host thousands of community-maintained charts for popular applications (databases, caches, monitoring tools, etc.). This dramatically speeds up the deployment of complex, multi-service applications.
The next step after mastering basic chart creation is understanding Helm hooks for advanced lifecycle management.