DevOps from Zero to Hero: Helm Charts
Support this blog
If you find this content useful, consider supporting the blog.
Introduction
Welcome to article twelve of the DevOps from Zero to Hero series. In the previous articles we learned how to deploy containers to Kubernetes using raw YAML manifests. That works fine when you have a single service with a handful of files, but as your applications grow and you start managing multiple environments, raw manifests become painful to maintain.
Imagine you have a deployment, a service, an ingress, a configmap, and an HPA. Now multiply that by three environments (dev, staging, production) where only a few values change: the image tag, the replica count, the domain name. Suddenly you are copy-pasting YAML files, searching and replacing values by hand, and praying you did not miss something. This is exactly the problem Helm solves.
Helm is the package manager for Kubernetes. It lets you define your application as a reusable template,
parameterize the parts that change, version the whole thing, and install or upgrade it with a single
command. If you have ever used apt, brew, or npm, Helm fills the same role for Kubernetes.
In this article we will cover what Helm is and why it exists, create a chart from scratch, dive into template syntax and helpers, package a TypeScript API with all the resources it needs, manage releases with install, upgrade, and rollback, push charts to OCI registries, and test our work. If you want to see how Helm was used years ago, check out Getting started with Helm and Deploying my apps with Helm, but keep in mind those articles are from 2018 and cover Helm 2 which is now deprecated.
Let’s get into it.
What is Helm?
Helm calls itself the package manager for Kubernetes, and that is a good description. There are four core concepts you need to understand:
- Chart: A collection of files that describe a related set of Kubernetes resources. Think of it as a package. A chart contains templates, default values, metadata, and optionally sub-charts for dependencies.
- Release: A specific instance of a chart running in your cluster. You can install the same chart multiple times with different configurations, and each installation is a separate release with its own name and history.
- Repository: A place where charts are stored and shared. This can be a traditional HTTP server, a Helm repository, or an OCI-compliant container registry (the modern approach).
- Values: The configuration parameters that customize a chart for a specific deployment. You provide values to override the chart’s defaults, and Helm uses them to render the templates into valid Kubernetes manifests.
Here is how these pieces fit together:
Chart (package definition)
+ Values (your configuration)
= Release (running instance in your cluster)
├── Deployment (rendered from template)
├── Service (rendered from template)
├── Ingress (rendered from template)
└── ConfigMap (rendered from template)
Why Helm over raw manifests
You might be wondering if you really need another tool. Here is what Helm gives you that raw YAML does not:
- Templating: Write your manifests once with placeholders, and render them with different values for each environment. No more copy-pasting YAML files.
- Versioning: Every chart has a version. Every release tracks which version was installed and what values were used. You always know what is running.
- Rollback: Made a bad deployment?
helm rollbacktakes you back to the previous working state in seconds. Helm keeps a history of every release revision.- Dependency management: Your application depends on Redis? Add it as a chart dependency and Helm installs both together.
- Sharing: Package your chart and push it to a registry. Anyone on your team (or the world) can install it with a single command.
- Lifecycle hooks: Run jobs before or after install, upgrade, or delete. Great for database migrations, cache warming, or health checks.
The alternative is managing raw YAML with Kustomize or hand-rolled scripts. Kustomize is built into kubectl and works well for simple overlay scenarios, but it does not give you versioning, rollback, or a release history. For most teams, Helm is the better choice once you move beyond trivial deployments.
Helm 3 vs Helm 2: a brief history
If you have seen older Helm tutorials, they mention something called Tiller. That was a server-side component that Helm 2 required to run inside your cluster. Tiller had cluster-admin permissions and was a significant security concern.
Helm 3 (released in November 2019) removed Tiller entirely. Here is what changed:
- No Tiller: Helm now talks directly to the Kubernetes API using your kubeconfig credentials. No more deploying a privileged pod into your cluster.
- Three-way strategic merge: Helm 3 compares the old manifest, the new manifest, and the live state in the cluster. This means manual changes to resources are detected and handled properly during upgrades.
- Release namespaces: Releases are stored as Kubernetes secrets in the namespace where they are deployed, not in a central Tiller namespace.
- JSON Schema validation: Charts can include a
values.schema.jsonfile to validate user-provided values before rendering.- OCI registry support: Charts can be stored in container registries like Docker Hub, GHCR, or ECR, just like container images.
If you are starting today, you will only ever use Helm 3. The helm binary you install from the
official site is Helm 3. Helm 2 reached end of life in November 2020, so there is no reason to use
it for new projects.
Installing Helm
Installing Helm is straightforward. Pick the method that matches your system:
# macOS with Homebrew
brew install helm
# Linux with the official install script
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Arch Linux
pacman -S helm
# Verify the installation
helm version
You should see output like:
version.BuildInfo{Version:"v3.17.x", GitCommit:"...", GitTreeState:"clean", GoVersion:"go1.23.x"}
Creating a chart from scratch
Let’s create our first chart. Helm provides a scaffolding command:
helm create task-api
This creates a directory structure with everything you need:
task-api/
├── Chart.yaml # Metadata: name, version, description
├── values.yaml # Default configuration values
├── charts/ # Sub-charts (dependencies)
├── templates/ # Kubernetes manifest templates
│ ├── _helpers.tpl # Named template helpers
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ ├── serviceaccount.yaml
│ ├── NOTES.txt # Post-install instructions shown to the user
│ └── tests/
│ └── test-connection.yaml
└── .helmignore # Files to exclude when packaging
Let’s go through the key files one by one.
Chart.yaml: the chart metadata
This file defines who your chart is. Think of it like a package.json for Helm:
apiVersion: v2
name: task-api
description: A Helm chart for the Task API TypeScript application
type: application
version: 0.1.0
appVersion: "1.0.0"
- apiVersion: Always
v2for Helm 3 charts. Helm 2 usedv1.- name: The name of the chart. Must be lowercase and may contain hyphens.
- description: A short description displayed when searching repositories.
- type: Either
application(deploys resources) orlibrary(only provides helpers for other charts).- version: The chart version. Follows semantic versioning. Bump this every time you change the chart.
- appVersion: The version of the application being deployed. This is informational and does not affect chart behavior.
The distinction between version and appVersion is important. The chart version tracks changes
to the chart itself (templates, defaults). The app version tracks which version of your application
the chart deploys. They evolve independently.
values.yaml: the default configuration
This is the most important file in a chart. It defines every configurable parameter with sensible defaults:
replicaCount: 2
image:
repository: ghcr.io/your-org/task-api
pullPolicy: IfNotPresent
tag: ""
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
service:
type: ClusterIP
port: 3000
ingress:
enabled: false
className: "traefik"
annotations: {}
hosts:
- host: task-api.example.com
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80
env:
NODE_ENV: production
PORT: "3000"
A few principles for structuring values:
- Group related settings: Put all image-related values under
image, all ingress settings underingress, and so on. This makes it easy to find and override things.- Provide sensible defaults: The chart should work with zero overrides for a basic deployment. Production-specific settings (domain names, resource limits) are what users override.
- Use flat keys where possible: Deeply nested values are harder to override with
--set. Keep the nesting reasonable.- Document with comments: Add comments explaining what each value does, what valid options are, and what the default means.
Template syntax: Go templates
Helm templates use Go’s text/template package with some extra functions from the Sprig library.
If you have never seen Go templates before, here is a quick introduction.
The basic syntax uses double curly braces {{ }} to insert dynamic content. Everything outside the
braces is rendered as-is. Let’s look at the core patterns:
# Simple value substitution
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
labels:
app: {{ .Chart.Name }}
version: {{ .Chart.AppVersion }}
spec:
replicas: {{ .Values.replicaCount }}
Helm provides several built-in objects that you can access in templates:
.Values: The merged result of values.yaml and any overrides the user provided. This is where most of your dynamic data comes from..Release: Information about the current release..Release.Nameis the release name,.Release.Namespaceis the namespace,.Release.IsUpgradetells you if this is an upgrade..Chart: Contents of Chart.yaml..Chart.Name,.Chart.Version,.Chart.AppVersion..Template: Information about the current template file. Mostly used for debugging..Capabilities: Information about the Kubernetes cluster..Capabilities.APIVersionslets you check if a specific API version exists.
Conditionals and loops
Templates support control flow. Here is how to conditionally include an ingress and loop over hosts:
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "task-api.fullname" . }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "task-api.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
{{- end }}
A few things to notice:
{{- ... }}: The dash trims whitespace before the tag. Without it, you get blank lines in the output.range: Loops over a list or map. Inside the loop,.refers to the current item.$: Refers to the root scope. When you are inside arangeblock,.changes to the current item. Use$to access.Valuesor.Releasefrom within a loop.with: Sets the scope of.to the specified object. If the object is empty, the block is skipped entirely. It works like a combined “if not empty” and “set scope.”toYaml: Converts a Go data structure to YAML. Combined withnindent, it handles indentation correctly.quote: Wraps the value in double quotes. Always quote hostnames and strings that might contain special characters.
Helpers: _helpers.tpl
The _helpers.tpl file (the underscore prefix tells Helm not to render it as a manifest) contains
reusable named templates. These are like functions you can call from any template:
{{/*
Expand the name of the chart.
*/}}
{{- define "task-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "task-api.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "task-api.labels" -}}
helm.sh/chart: {{ include "task-api.chart" . }}
{{ include "task-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "task-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "task-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
You call these named templates using include:
metadata:
name: {{ include "task-api.fullname" . }}
labels:
{{- include "task-api.labels" . | nindent 4 }}
Why include instead of template? The template action outputs text directly and cannot be
piped to other functions. include returns the output as a string, so you can pipe it to nindent,
trim, or any other function. Always prefer include over template.
The trunc 63 calls throughout the helpers are not arbitrary. Kubernetes labels and names have a
63-character limit (DNS label rules from RFC 1123). The helpers enforce this automatically.
Packaging a TypeScript API: the full chart
Let’s build a complete chart for our task API from the series. We need a deployment, a service, an ingress, a configmap, and an HPA. We already saw the ingress above, so let’s cover the rest.
Deployment template (templates/deployment.yaml):
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "task-api.fullname" . }}
labels:
{{- include "task-api.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "task-api.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
labels:
{{- include "task-api.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "task-api.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "task-api.fullname" . }}-config
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 15
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.resources | nindent 12 }}
Notice the checksum/config annotation on the pod template. This is a common Helm pattern. When
you change a ConfigMap, Kubernetes does not automatically restart the pods that use it. By hashing
the ConfigMap content into an annotation, any change to the ConfigMap produces a different hash,
which triggers a rolling update. Clever and simple.
Also notice that when autoscaling is enabled, we skip the replicas field. The HPA manages the
replica count in that case, and setting it in the deployment would conflict.
ConfigMap template (templates/configmap.yaml):
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "task-api.fullname" . }}-config
labels:
{{- include "task-api.labels" . | nindent 4 }}
data:
{{- range $key, $value := .Values.env }}
{{ $key }}: {{ $value | quote }}
{{- end }}
This loops over every key-value pair in .Values.env and creates a ConfigMap entry. You add new
environment variables just by adding them to values.yaml, no template changes needed.
Service template (templates/service.yaml):
apiVersion: v1
kind: Service
metadata:
name: {{ include "task-api.fullname" . }}
labels:
{{- include "task-api.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "task-api.selectorLabels" . | nindent 4 }}
HPA template (templates/hpa.yaml):
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "task-api.fullname" . }}
labels:
{{- include "task-api.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "task-api.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
The entire HPA template is wrapped in an if block. When autoscaling is disabled (the default),
this file produces no output at all.
Overriding values
There are two main ways to override the defaults in values.yaml when you install or upgrade a release.
Using --set for individual values:
helm install my-api ./task-api \
--set image.tag=v1.2.3 \
--set replicaCount=3 \
--set ingress.enabled=true
Using -f (or --values) with a file:
helm install my-api ./task-api -f production-values.yaml
Where production-values.yaml might look like:
replicaCount: 3
image:
tag: v1.2.3
ingress:
enabled: true
className: traefik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: api.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: api-tls
hosts:
- api.example.com
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 70
env:
NODE_ENV: production
PORT: "3000"
LOG_LEVEL: info
The file approach is better for anything beyond a couple of values. You can version control your
environment-specific files (dev-values.yaml, staging-values.yaml, production-values.yaml)
and get all the benefits of Git history for configuration changes.
You can also combine both approaches. Values from -f files are applied first, then --set
overrides on top. This is useful when you want a base file plus a one-off override:
helm install my-api ./task-api \
-f production-values.yaml \
--set image.tag=v1.2.4
Installing and managing releases
Here is the full lifecycle of a Helm release.
Install a release:
# Install from a local chart directory
helm install my-api ./task-api -n task-api --create-namespace
# Install from a repository
helm install my-api my-repo/task-api -n task-api --create-namespace
# Install and wait for all pods to be ready
helm install my-api ./task-api -n task-api --create-namespace --wait --timeout 5m
The --wait flag tells Helm to wait until all resources are in a ready state before marking the
release as successful. Combined with --timeout, this gives you a clear success or failure signal.
Without --wait, Helm marks the release as deployed as soon as the manifests are submitted to the
API server, regardless of whether the pods actually start.
Check release status:
# List all releases in a namespace
helm list -n task-api
# Get detailed status of a release
helm status my-api -n task-api
# See the values that were used for the current release
helm get values my-api -n task-api
# See all values (including defaults)
helm get values my-api -n task-api --all
# See the rendered manifests
helm get manifest my-api -n task-api
Upgrade a release:
# Upgrade with a new image tag
helm upgrade my-api ./task-api -n task-api --set image.tag=v1.3.0
# Upgrade with a values file
helm upgrade my-api ./task-api -n task-api -f production-values.yaml
# Install or upgrade (idempotent, great for CI/CD)
helm upgrade --install my-api ./task-api -n task-api -f production-values.yaml
The upgrade --install pattern is the most common in CI/CD pipelines. It installs the release if
it does not exist, or upgrades it if it does. This makes your pipeline idempotent, you can run it
multiple times without errors.
View release history:
helm history my-api -n task-api
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 2026-05-24 10:00:00 superseded task-api-0.1.0 1.0.0 Install complete
2 2026-05-24 14:30:00 superseded task-api-0.1.0 1.1.0 Upgrade complete
3 2026-05-24 15:00:00 deployed task-api-0.2.0 1.2.0 Upgrade complete
Rollback to a previous revision:
# Rollback to the previous revision
helm rollback my-api -n task-api
# Rollback to a specific revision
helm rollback my-api 1 -n task-api
Rollback is one of the strongest arguments for Helm. If a deployment goes wrong, you can revert to any previous state in seconds. No need to figure out which YAML files to apply or which image tag was running before. Helm tracks all of that for you.
Uninstall a release:
# Remove the release and all its resources
helm uninstall my-api -n task-api
# Keep the release history (useful for auditing)
helm uninstall my-api -n task-api --keep-history
OCI registries: the modern approach
Traditional Helm repositories are HTTP servers that host an index.yaml file listing all available
charts. They work, but they require maintaining a separate piece of infrastructure.
The modern approach is to store Helm charts in OCI-compliant container registries, the same registries you already use for Docker images. GitHub Container Registry (GHCR), Docker Hub, ECR, GCR, and Azure Container Registry all support Helm OCI charts.
Here is how to package and push a chart to GHCR:
# Log in to GHCR
echo $GITHUB_TOKEN | helm registry login ghcr.io --username your-username --password-stdin
# Package the chart
helm package ./task-api
# This creates task-api-0.1.0.tgz in the current directory
# Push to GHCR
helm push task-api-0.1.0.tgz oci://ghcr.io/your-org/charts
# Pull from GHCR
helm pull oci://ghcr.io/your-org/charts/task-api --version 0.1.0
# Install directly from GHCR
helm install my-api oci://ghcr.io/your-org/charts/task-api --version 0.1.0 -n task-api
The OCI approach has several advantages:
- No index.yaml: No need to rebuild and host a chart index. The registry handles discovery.
- Same infrastructure: If you already use GHCR for Docker images, you do not need to set up anything else.
- Access control: Registry permissions apply to charts the same way they apply to images.
- Immutable tags: Once you push a version, it cannot be overwritten (depending on registry settings). This guarantees reproducibility.
In a CI/CD pipeline, you would build and push the chart alongside the Docker image:
# .github/workflows/release.yaml (relevant excerpt)
- name: Push Helm chart to GHCR
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \
--username ${{ github.actor }} --password-stdin
helm package ./charts/task-api --version ${{ github.ref_name }}
helm push task-api-${{ github.ref_name }}.tgz oci://ghcr.io/${{ github.repository_owner }}/charts
Chart testing
Before you install a chart in a real cluster, you should validate it. Helm provides several tools for this.
Linting:
# Check for issues in chart structure and templates
helm lint ./task-api
# Lint with specific values
helm lint ./task-api -f production-values.yaml
helm lint catches common mistakes: missing required fields in Chart.yaml, template syntax errors,
indentation problems, and deprecated API versions. Run it in CI on every pull request.
Template rendering:
# Render templates without installing
helm template my-api ./task-api
# Render with specific values and save to a file for review
helm template my-api ./task-api -f production-values.yaml > rendered.yaml
# Render and validate against the cluster's API
helm template my-api ./task-api --validate
helm template renders the templates locally and prints the resulting YAML. This is incredibly
useful for debugging. If something looks wrong in the output, the problem is in your templates or
values, not in Kubernetes. The --validate flag adds API server validation, which catches issues
like using a removed API version.
Release testing:
# Run the chart's test pods
helm test my-api -n task-api
Helm supports test hooks. These are pods defined in templates/tests/ that run when you execute
helm test. A typical test verifies that the deployed application is reachable:
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "task-api.fullname" . }}-test-connection"
labels:
{{- include "task-api.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "task-api.fullname" . }}:{{ .Values.service.port }}/health']
restartPolicy: Never
This pod runs wget against the service’s health endpoint. If it succeeds, the test passes. If it
fails, you know something is wrong with the deployment.
Managing multiple charts: Helmfile and ArgoCD
Once you have more than a handful of charts, you need a way to manage them together. Two tools stand out.
Helmfile is a declarative spec for deploying multiple Helm charts. Instead of running helm install and helm upgrade commands manually, you define everything in a helmfile.yaml:
# helmfile.yaml
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
releases:
- name: task-api
namespace: task-api
chart: ./charts/task-api
values:
- environments/{{ .Environment.Name }}/task-api.yaml
- name: redis
namespace: task-api
chart: bitnami/redis
version: 18.6.1
values:
- environments/{{ .Environment.Name }}/redis.yaml
Then deploy everything with:
helmfile -e production apply
ArgoCD takes a different approach. Instead of running commands, you define your desired state in Git and ArgoCD continuously reconciles the cluster to match. ArgoCD has native Helm support, so you point it at a Git repository containing your chart and values, and it handles the rest:
# ArgoCD Application manifest
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: task-api
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-org/your-repo
targetRevision: main
path: charts/task-api
helm:
valueFiles:
- ../../environments/production/task-api.yaml
destination:
server: https://kubernetes.default.svc
namespace: task-api
syncPolicy:
automated:
selfHeal: true
prune: true
syncOptions:
- CreateNamespace=true
ArgoCD is the GitOps approach. Every change goes through a pull request, gets reviewed, merged to
main, and ArgoCD applies it automatically. No one runs helm install or kubectl apply manually.
This is how most production teams operate today. If you want to dig deeper into ArgoCD, check out
GitOps with ArgoCD from the SRE series.
Common patterns and tips
Here are some patterns you will encounter often when working with Helm.
Use helm upgrade --install in CI/CD. This makes deployments idempotent. Whether the release
exists or not, the command does the right thing.
Always set resource requests and limits. The default values.yaml should include reasonable resource values. Without them, a single pod can consume all cluster resources.
Use the checksum annotation pattern. As we saw earlier, hashing ConfigMap content into a pod annotation triggers rolling updates when configuration changes. This saves you from the “I changed the ConfigMap but nothing happened” surprise.
Pin your chart versions. When installing from a repository, always specify --version. Without
it, Helm installs the latest version, which might introduce breaking changes.
Keep secrets out of values.yaml. Never put passwords, API keys, or tokens in values files that get committed to Git. Use Kubernetes Secrets managed by an external tool like External Secrets Operator or Sealed Secrets.
Use helm diff for safe upgrades. The helm-diff plugin shows you exactly what will change
before you upgrade:
# Install the diff plugin
helm plugin install https://github.com/databus23/helm-diff
# Preview changes before upgrading
helm diff upgrade my-api ./task-api -f production-values.yaml -n task-api
This is especially valuable in production where you want to review changes before applying them.
Closing notes
Helm takes the pain out of managing Kubernetes applications. Instead of juggling raw YAML files across environments, you define your application once as a chart, parameterize the things that change, and let Helm handle the rendering, versioning, and lifecycle management.
In this article we covered what Helm is and why it exists, created a chart from scratch with all the templates a real application needs, explored Go template syntax including conditionals, loops, and built-in objects, built reusable helpers, managed releases with install, upgrade, rollback, and history, pushed charts to OCI registries for modern distribution, and validated everything with lint, template, and test.
The key takeaway is that Helm is not just about templating YAML. It is about giving your Kubernetes deployments a proper lifecycle: versioned releases, configuration management, rollback capability, and a shared language for your team to talk about what is running where.
In the next article we will look at monitoring and observability for our Kubernetes workloads, because deploying an application is only half the job. You also need to know if it is healthy and performing well.
Hope you found this useful and enjoyed reading it, until next time!
Errata
If you spot any error or have any suggestion, please send me a message so it gets fixed.
Also, you can check the source code and changes in the sources here
$ Comments
Online: 0Please sign in to be able to write comments.