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
- What Helm Actually Is
- Creating Your First Helm Chart
- Writing Helm Templates
- Environment-Specific Values
- Testing Before Deploying
- Deploying with Helm
- Wiring Helm into ArgoCD
- Key Helm Concepts to Remember
- Helm vs Raw YAML — When to Use Each
- What's Next
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
| Situation | Use |
|---|---|
| Learning, single environment | Raw YAML — simpler to understand |
| Multiple environments | Helm — eliminates duplication |
| Third-party apps (Prometheus, ArgoCD) | Helm — community charts are maintained |
| Simple one-off resources | Raw YAML — Helm overhead isn't worth it |
| Team sharing infrastructure | Helm — 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.