DevOps from Zero to Hero: Helm Charts

2026-05-24 | Gabriel Garrido | 22 min read
Share:

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 rollback takes 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.json file 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 v2 for Helm 3 charts. Helm 2 used v1.
  • 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) or library (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 under ingress, 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.Name is the release name, .Release.Namespace is the namespace, .Release.IsUpgrade tells 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.APIVersions lets 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 a range block, . changes to the current item. Use $ to access .Values or .Release from 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 with nindent, 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: 0

Please sign in to be able to write comments.

2026-05-24 | Gabriel Garrido