Helm in CI/CD Pipelines

Integrate Helm into your CI/CD workflows. Automate chart testing, building, and deployment with GitHub Actions and more.

7 min read

Helm in CI/CD Pipelines

In the previous tutorial, we learned how to test Helm charts thoroughly. Now let's wire everything into CI/CD pipelines so charts are tested, published, and deployed automatically. No more manual helm upgrade in production. Ever.

The CI/CD Workflow

Here's what a complete Helm CI/CD pipeline looks like:

PR Opened / Push to Main
│
├── Lint & Validate
│   ├── helm lint --strict
│   ├── helm template | kubeconform
│   └── helm unittest
│
├── Integration Test (on PR)
│   ├── Deploy to test cluster
│   ├── helm test
│   └── Cleanup
│
├── Package & Publish (on main)
│   ├── helm package
│   └── Push to OCI / chart repo
│
└── Deploy (on main / tag)
    ├── Deploy to staging (auto)
    └── Deploy to production (manual approval)

GitHub Actions — Chart Testing

Lint and Unit Test on PRs

# .github/workflows/chart-test.yml
name: Chart Testing

on:
  pull_request:
    paths:
      - 'charts/**'

jobs:
  lint-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Helm
        uses: azure/setup-helm@v3
        with:
          version: v3.14.0

      - name: Install helm-unittest
        run: helm plugin install https://github.com/helm-unittest/helm-unittest

      - name: Lint charts
        run: |
          for chart in charts/*/; do
            echo "Linting $chart..."
            helm lint "$chart" --strict
          done

      - name: Run unit tests
        run: |
          for chart in charts/*/; do
            if [ -d "$chart/tests" ]; then
              echo "Testing $chart..."
              helm unittest "$chart"
            fi
          done

      - name: Validate rendered manifests
        run: |
          brew install kubeconform
          for chart in charts/*/; do
            echo "Validating $chart..."
            helm template test "$chart" | kubeconform -strict -kubernetes-version 1.28.0
          done

Integration Test with Kind

For a full integration test, spin up a local Kubernetes cluster:

# .github/workflows/chart-integration.yml
name: Chart Integration Test

on:
  pull_request:
    paths:
      - 'charts/**'

jobs:
  integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Helm
        uses: azure/setup-helm@v3

      - name: Create Kind cluster
        uses: helm/kind-action@v1

      - name: Set up chart-testing
        uses: helm/chart-testing-action@v2

      - name: Detect changed charts
        id: changed
        run: |
          changed=$(ct list-changed --target-branch main)
          if [ -n "$changed" ]; then
            echo "changed=true" >> $GITHUB_OUTPUT
          fi

      - name: Lint and install changed charts
        if: steps.changed.outputs.changed == 'true'
        run: ct lint-and-install --target-branch main

GitHub Actions — Release and Publish

Publish to OCI Registry

# .github/workflows/release-oci.yml
name: Release Charts (OCI)

on:
  push:
    branches: [main]
    paths:
      - 'charts/**'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      packages: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Set up Helm
        uses: azure/setup-helm@v3

      - name: Login to GHCR
        run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | \
            helm registry login ghcr.io -u ${{ github.actor }} --password-stdin

      - name: Package and push charts
        run: |
          for chart in charts/*/; do
            chart_name=$(basename "$chart")
            chart_version=$(grep '^version:' "$chart/Chart.yaml" | awk '{print $2}')

            echo "Packaging $chart_name v$chart_version..."
            helm package "$chart" --dependency-update

            echo "Pushing to GHCR..."
            helm push "${chart_name}-${chart_version}.tgz" \
              oci://ghcr.io/${{ github.repository_owner }}/charts
          done

Publish to GitHub Pages

Using chart-releaser:

# .github/workflows/release-pages.yml
name: Release Charts (GitHub Pages)

on:
  push:
    branches: [main]
    paths:
      - 'charts/**'

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Configure Git
        run: |
          git config user.name "$GITHUB_ACTOR"
          git config user.email "$GITHUB_ACTOR@users.noreply.github.com"

      - name: Set up Helm
        uses: azure/setup-helm@v3

      - name: Run chart-releaser
        uses: helm/chart-releaser-action@v1
        env:
          CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

GitHub Actions — Deployment

Deploy to Kubernetes

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/checkout@v4

      - name: Set up Helm
        uses: azure/setup-helm@v3

      - name: Configure kubectl
        uses: azure/k8s-set-context@v3
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.KUBE_CONFIG_STAGING }}

      - name: Deploy to staging
        run: |
          helm upgrade --install my-app ./charts/my-app \
            --namespace staging \
            --create-namespace \
            -f charts/my-app/values-staging.yaml \
            --set image.tag=${{ github.sha }} \
            --atomic \
            --timeout 10m \
            --wait

      - name: Run integration tests
        run: helm test my-app --namespace staging --timeout 5m

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4

      - name: Set up Helm
        uses: azure/setup-helm@v3

      - name: Configure kubectl
        uses: azure/k8s-set-context@v3
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.KUBE_CONFIG_PRODUCTION }}

      - name: Deploy to production
        run: |
          helm upgrade --install my-app ./charts/my-app \
            --namespace production \
            --create-namespace \
            -f charts/my-app/values-production.yaml \
            --set image.tag=${{ github.sha }} \
            --atomic \
            --timeout 10m \
            --wait

Environment-Specific Values

Structure your values files by environment:

charts/my-app/
├── Chart.yaml
├── values.yaml              # Defaults (dev-friendly)
├── values-staging.yaml      # Staging overrides
├── values-production.yaml   # Production overrides
└── templates/
# values.yaml (defaults — dev)
replicaCount: 1
image:
  repository: my-app
  tag: "latest"
resources:
  limits:
    cpu: 100m
    memory: 128Mi

# values-staging.yaml
replicaCount: 2
resources:
  limits:
    cpu: 250m
    memory: 256Mi

# values-production.yaml
replicaCount: 5
resources:
  limits:
    cpu: 1000m
    memory: 1Gi
ingress:
  enabled: true
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
autoscaling:
  enabled: true
  minReplicas: 5
  maxReplicas: 20

Helmfile — Managing Multiple Releases

When you deploy many services, managing individual helm upgrade commands gets tedious. Helmfile provides a declarative way to manage multiple Helm releases:

# helmfile.yaml
repositories:
  - name: bitnami
    url: https://charts.bitnami.com/bitnami

environments:
  staging:
    values:
      - environments/staging.yaml
  production:
    values:
      - environments/production.yaml

releases:
  - name: api
    chart: ./charts/api
    namespace: "{{ .Environment.Name }}"
    values:
      - charts/api/values.yaml
      - charts/api/values-{{ .Environment.Name }}.yaml
    set:
      - name: image.tag
        value: "{{ requiredEnv \"IMAGE_TAG\" }}"

  - name: worker
    chart: ./charts/worker
    namespace: "{{ .Environment.Name }}"
    values:
      - charts/worker/values.yaml
      - charts/worker/values-{{ .Environment.Name }}.yaml

  - name: redis
    chart: bitnami/redis
    version: 18.4.0
    namespace: "{{ .Environment.Name }}"
    values:
      - config/redis-{{ .Environment.Name }}.yaml
# Deploy everything to staging
helmfile -e staging apply

# Deploy to production
helmfile -e production apply

# Diff before applying
helmfile -e production diff

# Sync a specific release
helmfile -e staging -l name=api apply

Helmfile in CI

# .github/workflows/deploy-helmfile.yml
- name: Deploy with Helmfile
  run: |
    helmfile -e ${{ inputs.environment }} apply
  env:
    IMAGE_TAG: ${{ github.sha }}

ArgoCD — GitOps Deployment

For GitOps workflows, ArgoCD watches your Git repo and automatically syncs Helm releases:

# argo-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/deployments.git
    targetRevision: main
    path: charts/my-app
    helm:
      valueFiles:
        - values-production.yaml
      parameters:
        - name: image.tag
          value: "latest"  # Or use image updater
  destination:
    server: https://kubernetes.default.svc
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true

With ArgoCD:

  1. Merge to main triggers a sync
  2. ArgoCD renders the Helm chart
  3. Diffs against live state
  4. Applies changes automatically (or waits for approval)
  5. Monitors health and reports status

Best Practices for Helm in CI/CD

  1. Pin chart and app versions — Never deploy unversioned charts. Tag images with git SHA, not latest.

  2. Use --atomic — Auto-rollback on failure. There's no reason not to.

  3. Use --wait — Don't mark a deploy as successful until pods are ready.

  4. Store values in Git — Track environment config alongside your chart for full auditability.

  5. Use environments with approvals — Production deploys should require human approval (GitHub Environments, ArgoCD sync windows).

  6. Namespace per environment — Isolate staging from production.

  7. Test before deploy — The pipeline should run lint → unit test → integration test → deploy staging → deploy production.

  8. Helm diff before apply — In non-automated flows, always review what will change.

What's Next?

You've made it! You've gone from "what is Helm?" to running production CI/CD pipelines. Here's what you covered across this entire series:

  • Basics: What Helm is, installing it, creating your first chart
  • Structure: Chart anatomy, values, built-in objects
  • Templating: Functions, pipelines, flow control, named templates
  • Advanced: Dependencies, hooks, releases, rollbacks
  • Distribution: Repositories, packaging, OCI registries
  • Quality: Linting, unit tests, integration tests
  • Automation: CI/CD pipelines, Helmfile, GitOps

From here, real mastery comes from practice. Build charts for your own applications, contribute to open-source charts, and explore the Helm documentation for edge cases.

Happy Helming! 🎉