0x55aa
← Back to Blog

Helm Charts: Stop Copy-Pasting Kubernetes YAML Across Environments šŸŽ©āš“

•9 min read

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 environments
  • helm upgrade --install is your go-to command for CI/CD deploys
  • --set image.tag=$SHA injects build-specific values without modifying files
  • Helm hooks (pre-upgrade) handle database migrations before code rolls out
  • helm rollback is 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. šŸŽ©