Hands-on Kubernetes & DevOpsPart 4 of 5 — open for all chapters

February 6, 2026

Helm: Packaging Kubernetes Applications for Multiple Environments

Use Helm charts and values files so one app deploys cleanly to dev, staging, and prod without copy-pasted YAML.

Quick navigation

The Problem Helm Solves

After Day 5, your gitops/my-api/ folder has four YAML files totalling around 120 lines. Everything is hardcoded — the image tag, the replica count, the resource limits, the environment name.

Now imagine you need to deploy this same app to three environments: dev, staging, and production. Each needs different replica counts, different resource limits, different domain names, and different config values.

Without Helm, you have three copies of those 120 lines — 360 lines of nearly identical YAML that drift apart over time as people update one environment and forget the others.

Helm solves this with templates. Write your YAML once with variables, define the values separately per environment, and Helm renders the final manifests at deploy time.

Stack: Helm 3 · Kubernetes · ArgoCD · YAML templating


What Helm Actually Is

Helm has three concepts:

Chart — a packaged Kubernetes application. It's a folder containing templates (YAML with variables) and default values.

Values — a values.yaml file that fills in the variables. Override it per environment without touching the templates.

Release — a deployed instance of a chart. The same chart deployed twice with different values creates two independent releases.

Chart (template) + Values (variables) = Release (running in K8s)

Think of a chart like a function and values like the arguments. Call the same function with different arguments and you get different results.


Creating Your First Helm Chart

cd k8s-portfolio
# Scaffold a new chart
helm create charts/my-api

This generates a folder structure:

charts/my-api/
├── Chart.yaml # metadata — name, version, description
├── values.yaml # default values (overridden per environment)
└── templates/ # YAML templates with {{ }} variables
├── deployment.yaml
├── service.yaml
├── configmap.yaml
└── _helpers.tpl # reusable template functions

Delete the generated template contents — you'll replace them with your own:

rm charts/my-api/templates/*.yaml
rm charts/my-api/templates/NOTES.txt

Writing Helm Templates

Helm uses Go templating syntax. Variables look like {{ .Values.replicaCount }}. Everything under .Values comes from your values.yaml.

charts/my-api/Chart.yaml:

apiVersion: v2
name: my-api
description: Production Node.js API
type: application
version: 0.1.0 # chart version — bump when you change the chart
appVersion: "1.0.0" # your app version — informational

charts/my-api/values.yaml — the defaults, used when no override is provided:

replicaCount: 2
image:
  repository: my-api
  tag: local
  pullPolicy: Never # local dev — never pull from registry
service:
  port: 80
  targetPort: 3000
resources:
  requests:
    memory: "64Mi"
    cpu: "100m"
  limits:
    memory: "128Mi"
    cpu: "200m"
config:
  appEnv: "production"
  appVersion: "1.0.0"
  logLevel: "info"
autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

charts/my-api/templates/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  labels:
    app: {{ .Release.Name }}
    chart: {{ .Chart.Name }}-{{ .Chart.Version }}
    environment: {{ .Values.config.appEnv }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      app: {{ .Release.Name }}
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}
    spec:
      containers:
        - name: {{ .Release.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          envFrom:
            - configMapRef:
                name: {{ .Release.Name }}-config
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          readinessProbe:
            httpGet:
              path: /health
              port: {{ .Values.service.targetPort }}
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: {{ .Values.service.targetPort }}
            initialDelaySeconds: 15
            periodSeconds: 20

charts/my-api/templates/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}-service
  labels:
    app: {{ .Release.Name }}
spec:
  selector:
    app: {{ .Release.Name }}
  ports:
    - name: http
      port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}
  type: ClusterIP

charts/my-api/templates/configmap.yaml:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-config
data:
  APP_ENV: {{ .Values.config.appEnv | quote }}
  APP_VERSION: {{ .Values.config.appVersion | quote }}
  LOG_LEVEL: {{ .Values.config.logLevel | quote }}

Environment-Specific Values

Create override files per environment — these only contain values that differ from the defaults:

charts/my-api/values-dev.yaml:

replicaCount: 1
resources:
  requests:
    memory: "32Mi"
    cpu: "50m"
  limits:
    memory: "64Mi"
    cpu: "100m"
config:
  appEnv: "development"
  logLevel: "debug"

charts/my-api/values-prod.yaml:

replicaCount: 3
resources:
  requests:
    memory: "128Mi"
    cpu: "200m"
  limits:
    memory: "256Mi"
    cpu: "500m"
autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20
  targetCPUUtilizationPercentage: 70
config:
  appEnv: "production"
  logLevel: "warn"

Three environments, one chart, three value files. The templates never change — only what gets rendered into them.


Testing Before Deploying

Helm has a dry-run mode that renders templates without applying them. Always use this before deploying:

# Render templates and print to stdout — no cluster changes
helm template my-api charts/my-api \
--values charts/my-api/values-dev.yaml
# Dry run against the cluster — validates against K8s API
helm install my-api-dev charts/my-api \
--values charts/my-api/values-dev.yaml \
--dry-run --debug
# Lint the chart for common mistakes
helm lint charts/my-api

helm template is your best friend when debugging template errors. It shows exactly what YAML Helm would generate before sending anything to Kubernetes.


Deploying with Helm

# Deploy to dev
helm install my-api-dev charts/my-api \
--values charts/my-api/values-dev.yaml \
--namespace dev \
--create-namespace
# Deploy to prod with different values
helm install my-api-prod charts/my-api \
--values charts/my-api/values-prod.yaml \
--namespace prod \
--create-namespace

Two releases from one chart. Check them:

helm list --all-namespaces
NAME NAMESPACE REVISION STATUS CHART
my-api-dev dev 1 deployed my-api-0.1.0
my-api-prod prod 1 deployed my-api-0.1.0

Update a value without touching templates:

helm upgrade my-api-dev charts/my-api \
--values charts/my-api/values-dev.yaml \
--set image.tag=abc1234

Rollback to the previous release:

helm rollback my-api-dev 1
# 1 = revision number from helm history

Every install and upgrade is a numbered revision. Rollback is instant and logged.


Wiring Helm into ArgoCD

ArgoCD natively supports Helm charts as a source. Update your argocd-app.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-api
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/kaungmyathan22/golang-k8s-portfolio
    targetRevision: HEAD
    path: charts/my-api # point at the chart folder
    helm:
      valueFiles:
        - values.yaml # base values
        - values-prod.yaml # environment override
  destination:
    server: https://kubernetes.default.svc
    namespace: default
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Now your GitOps workflow handles Helm too. Change a value in values-prod.yaml, push to Git, ArgoCD renders the Helm chart and applies the diff. The entire deployment — templating and application — is automated and auditable.


Key Helm Concepts to Remember

{{ .Release.Name }} — the name you give the release at install time. Use this instead of hardcoding your app name in templates so the same chart can be deployed multiple times with different names.

{{- toYaml .Values.resources | nindent 10 }} — converts a YAML block from values into properly indented YAML in the template. The - strips whitespace, nindent adds it back with correct indentation. You'll use this for any nested structure like resources, env vars, and tolerations.

{{- if .Values.autoscaling.enabled }} — conditional blocks. Include or exclude entire sections based on values. Production gets an HPA, dev doesn't — same chart.

{{ .Values.config.appEnv | quote }} — the quote function wraps strings in quotes. Always use it for ConfigMap values to prevent YAML parsing issues with values like true, false, or numbers that would be interpreted as non-strings without quotes.


Helm vs Raw YAML — When to Use Each

SituationUse
Learning, single environmentRaw YAML — simpler to understand
Multiple environmentsHelm — eliminates duplication
Third-party apps (Prometheus, ArgoCD)Helm — community charts are maintained
Simple one-off resourcesRaw YAML — Helm overhead isn't worth it
Team sharing infrastructureHelm — values files make customization explicit

What's Next

With Helm managing your app packaging and ArgoCD handling deployment, the infrastructure layer is solid. The next step is security — right now secrets live in Kubernetes Secrets which are only base64 encoded, not encrypted. HashiCorp Vault replaces this with proper secret management: encrypted at rest, audit logged, and capable of generating dynamic credentials that expire automatically.

The platform is built. Now we make it secure.


Part of a hands-on DevOps learning series. Code at github.com/kaungmyathan22/golang-k8s-portfolio.