Helm Charts: Stop Copy-Pasting Kubernetes YAML Across Environments š©ā
Helm Charts: Stop Copy-Pasting Kubernetes YAML Across Environments š©ā
Honest confession: For a shamefully long time, my "multi-environment Kubernetes strategy" was a k8s/ folder with three subfolders ā dev/, staging/, prod/ ā each containing the same six YAML files. Different by exactly the image tag, replica count, and resource limits. That's it.
You can probably see where this is going.
A colleague updated the health check endpoint in dev/deployment.yaml. Fixed a bug. Deployed. Worked great. Six weeks later, production was getting killed by a health check timing out because no one had updated prod/deployment.yaml. The YAML was 98% identical. Nobody noticed the drift. I was the one who got paged.
Docker taught me the hard way to stop treating configuration as something you "manage in your head." Helm taught me the same lesson for Kubernetes. Let me save you the 3 AM call.
The YAML Copy-Paste Problem š
Here's what raw Kubernetes YAML sprawl looks like in practice:
k8s/
āāā dev/
ā āāā deployment.yaml # replicas: 1, image: myapp:dev
ā āāā service.yaml
ā āāā ingress.yaml
ā āāā configmap.yaml # LOG_LEVEL: debug
ā āāā hpa.yaml # minReplicas: 1
āāā staging/
ā āāā deployment.yaml # replicas: 2, image: myapp:staging
ā āāā service.yaml # (identical to dev)
ā āāā ingress.yaml # different host
ā āāā configmap.yaml # LOG_LEVEL: info
ā āāā hpa.yaml # minReplicas: 2
āāā prod/
āāā deployment.yaml # replicas: 5, image: myapp:v1.4.2
āāā service.yaml # (identical to dev and staging)
āāā ingress.yaml # different host, TLS cert
āāā configmap.yaml # LOG_LEVEL: warning
āāā hpa.yaml # minReplicas: 3, maxReplicas: 20
Fifteen files. Twelve of them are essentially duplicates. Every structural change ā adding a sidecar, updating resource limits, changing a port ā requires touching all three environments and hoping you don't miss one.
This is not a workflow. This is a time bomb.
What Helm Actually Is š©
Helm is the package manager for Kubernetes. Think of it like apt or brew, but for deploying things to your cluster.
A Helm chart is a collection of templates ā your YAML files, but with placeholders ā plus a values.yaml file that fills in the environment-specific bits. You maintain one set of templates, and swap in different values per environment.
myapp/ ā this is your Helm chart
āāā Chart.yaml ā metadata (name, version, description)
āāā values.yaml ā default values
āāā values-staging.yaml ā staging overrides
āāā values-prod.yaml ā prod overrides
āāā templates/
āāā deployment.yaml ā ONE deployment template
āāā service.yaml ā ONE service template
āāā ingress.yaml ā ONE ingress template
āāā configmap.yaml ā ONE configmap template
āāā hpa.yaml ā ONE HPA template
Five template files instead of fifteen. When you fix a bug in the deployment template, it's fixed everywhere.
Your First Helm Template š
Here's what a templates/deployment.yaml looks like in a real Helm chart:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-app
labels:
app: {{ .Release.Name }}
version: {{ .Values.image.tag }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
resources:
requests:
memory: {{ .Values.resources.requests.memory }}
cpu: {{ .Values.resources.requests.cpu }}
limits:
memory: {{ .Values.resources.limits.memory }}
cpu: {{ .Values.resources.limits.cpu }}
env:
- name: LOG_LEVEL
value: {{ .Values.logLevel }}
livenessProbe:
httpGet:
path: {{ .Values.healthCheck.path }}
port: {{ .Values.service.port }}
initialDelaySeconds: 30
periodSeconds: 10
The {{ .Values.xxx }} placeholders get swapped out with real values at deploy time. That's the whole trick.
The Values Files āļø
Your values.yaml is the default configuration. Environment-specific files only override what's different:
# values.yaml (defaults ā used as base for everything)
replicaCount: 1
image:
repository: myapp
tag: "latest"
service:
port: 3000
logLevel: "debug"
healthCheck:
path: /health
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
# values-prod.yaml (only what's different in production)
replicaCount: 5
image:
tag: "v1.4.2" # pinned version, not 'latest'
logLevel: "warning"
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
Prod overrides exactly four things. Everything else inherits from defaults. When you update the health check path in values.yaml, every environment gets the update ā because they were never duplicating it.
A CI/CD pipeline that saved our team: after adopting Helm, we stopped having configuration drift between environments. The structural changes propagate automatically. Only the environment-specific values (replicas, image tags, resource limits) differ.
Deploying with Helm š ļø
# Install Helm (once)
brew install helm # macOS
# or: curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Deploy to dev (uses values.yaml defaults)
helm install myapp ./myapp
# Deploy to staging (merges values.yaml + values-staging.yaml)
helm install myapp ./myapp -f values-staging.yaml --namespace staging
# Deploy to prod (merges values.yaml + values-prod.yaml)
helm install myapp ./myapp -f values-prod.yaml --namespace prod
# Update an existing release (the Helm equivalent of kubectl apply)
helm upgrade myapp ./myapp -f values-prod.yaml --namespace prod
# Install OR upgrade in one command (most common in CI/CD)
helm upgrade --install myapp ./myapp \
-f values-prod.yaml \
--namespace prod \
--set image.tag=$GITHUB_SHA # override specific values inline
That --set image.tag=$GITHUB_SHA is how your CI/CD pipeline injects the exact build SHA without modifying any files.
GitHub Actions Integration š
After countless deployments, this is the pattern I use for Helm in GitHub Actions:
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-south-1
- name: Update kubeconfig
run: aws eks update-kubeconfig --name my-cluster --region ap-south-1
- name: Install Helm
uses: azure/setup-helm@v3
with:
version: "3.14.0"
- name: Deploy
run: |
helm upgrade --install myapp ./helm/myapp \
--namespace prod \
--create-namespace \
-f ./helm/myapp/values-prod.yaml \
--set image.tag=${{ github.sha }} \
--wait \
--timeout 5m
--wait makes the command block until all pods are healthy. If the deploy fails, the GitHub Actions step fails. You get a deployment result, not just a "YAML applied successfully."
Helm Hooks: Database Migrations Without the Pain šļø
This is the Helm feature I wish someone had told me about earlier. You can run a Job before your deployment rolls out using hooks:
# templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ .Release.Name }}-migrate
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["php", "artisan", "migrate", "--force"]
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secrets
key: db-password
helm.sh/hook: pre-upgrade means this Job runs before any pods are updated. Your migrations are applied before new code goes live. If the migration fails, the upgrade stops. No more "we deployed the new code but forgot to run migrations."
Before vs After š
| Scenario | Raw YAML (Before) | Helm (After) |
|---|---|---|
| Add a new env var | Edit 3 files (dev/staging/prod) | Edit 1 template + values |
| Update health check path | 3 files, hope you don't miss one | 1 values change, all envs updated |
| Roll back a bad deploy | kubectl apply -f old-version/ ... if you saved it |
helm rollback myapp 1 |
| See what's deployed | Check cluster + cross-reference Git | helm list |
| Config drift between envs | Inevitable | Structurally impossible |
| CI/CD image tag injection | Sed into files or multiple env files | --set image.tag=$SHA |
The rollback story deserves its own mention. Docker taught me the hard way that "we can always roll back" is a lie if you don't have the old YAML saved somewhere sane. With Helm, every release is versioned:
# See release history
helm history myapp --namespace prod
# Roll back to previous release
helm rollback myapp --namespace prod
# Roll back to a specific revision
helm rollback myapp 3 --namespace prod
One command. Helm keeps the last 10 releases by default. I've used this at 2 AM and it's beautiful.
Common Pitfalls to Avoid šŖ¤
Pitfall #1: Putting secrets in values files
values.yaml and values-prod.yaml often end up in Git. Never put database passwords there. Use Kubernetes Secrets and reference them in your templates with secretKeyRef. Better yet, use AWS Secrets Manager or HashiCorp Vault with the Helm secrets plugin.
Pitfall #2: Using latest as the image tag
Always override image.tag in your CI/CD pipeline with the actual SHA or version. latest is non-deterministic and will eventually bite you when a bad image gets deployed silently.
Pitfall #3: Ignoring --wait
Without --wait, Helm reports success as soon as it applies the manifests ā not when pods are actually running. You can get a "deploy succeeded" message from CI/CD while pods are crash-looping. Always use --wait --timeout 5m.
Pitfall #4: Giant monolithic charts Keep charts scoped to one service. I've seen "one chart for the entire application" setups where deploying a config change to one microservice required Helming the entire thing. One service, one chart.
TL;DR šÆ
- Helm is a package manager for Kubernetes ā one set of templates, multiple value files
{{ .Values.xxx }}placeholders replace the copy-paste across environmentshelm upgrade --installis your go-to command for CI/CD deploys--set image.tag=$SHAinjects build-specific values without modifying files- Helm hooks (
pre-upgrade) handle database migrations before code rolls out helm rollbackis the production emergency button you'll actually be glad exists- Never put secrets in values files ā use Kubernetes Secrets or an external secrets manager
After countless deployments where I chased configuration drift between environments like a particularly tedious game of Where's Waldo, Helm made that problem structurally impossible. One template, multiple environments, zero copy-paste.
Go delete those k8s/dev/, k8s/staging/, k8s/prod/ folders. You'll thank yourself during the next 2 AM incident.
Migrating a messy YAML setup to Helm? Hit me up on LinkedIn ā I've done it enough times to have opinions about the cleanest migration path.
Want to see real Helm chart examples? My GitHub has charts for Laravel and Node.js production setups.
Now go check if your values-prod.yaml has any hardcoded credentials in it. I'll wait. š©