DevOpsil
Helm
91%
Needs Review

Helmfile: Manage Multiple Helm Releases Like a Pro

Amara OkaforAmara Okafor6 min read

Once you're managing more than a handful of Helm releases across multiple environments, running individual helm upgrade commands starts to fall apart. You end up with shell scripts full of flags, inconsistent values between environments, and no reliable way to see what's actually deployed. Helmfile solves this by giving you a declarative, file-based way to manage your entire Helm release state — and it fits naturally into a GitOps workflow.

What Helmfile Actually Is

Helmfile is a wrapper around Helm. It reads a helmfile.yaml (or a directory of them) and translates your desired state into helm install, helm upgrade, and helm uninstall commands. The key difference from raw Helm: everything is declared in files, not in shell history.

# install
brew install helmfile

# or with asdf
asdf plugin add helmfile
asdf install helmfile 0.163.1
asdf global helmfile 0.163.1

# verify
helmfile version

A Real-World helmfile.yaml

Here's what a production helmfile looks like for a typical platform:

# helmfile.yaml
repositories:
  - name: bitnami
    url: https://charts.bitnami.com/bitnami
  - name: ingress-nginx
    url: https://kubernetes.github.io/ingress-nginx
  - name: cert-manager
    url: https://charts.jetstack.io
  - name: prometheus-community
    url: https://prometheus-community.github.io/helm-charts

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

releases:
  - name: ingress-nginx
    namespace: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.10.1
    values:
      - charts/ingress-nginx/values.yaml
      - charts/ingress-nginx/values-{{ .Environment.Name }}.yaml

  - name: cert-manager
    namespace: cert-manager
    chart: cert-manager/cert-manager
    version: v1.14.4
    values:
      - charts/cert-manager/values.yaml
    set:
      - name: installCRDs
        value: true

  - name: postgresql
    namespace: data
    chart: bitnami/postgresql
    version: 15.5.17
    values:
      - charts/postgresql/values.yaml
      - charts/postgresql/values-{{ .Environment.Name }}.yaml
    secrets:
      - charts/postgresql/secrets-{{ .Environment.Name }}.yaml

  - name: myapp
    namespace: default
    chart: ./charts/myapp
    version: ~> 1.4
    needs:
      - data/postgresql
      - ingress-nginx/ingress-nginx
    values:
      - charts/myapp/values.yaml
      - charts/myapp/values-{{ .Environment.Name }}.yaml

The {{ .Environment.Name }} template variable is one of the most useful Helmfile features — it lets a single release definition load different values files per environment. No duplication.

Directory Structure

For anything beyond a toy setup, organize your Helmfile config like this:

helmfile/
├── helmfile.yaml               # root file, imports all
├── environments/
│   ├── staging.yaml
│   └── production.yaml
├── charts/
│   ├── ingress-nginx/
│   │   ├── values.yaml
│   │   ├── values-staging.yaml
│   │   └── values-production.yaml
│   ├── cert-manager/
│   │   └── values.yaml
│   ├── postgresql/
│   │   ├── values.yaml
│   │   ├── values-staging.yaml
│   │   ├── values-production.yaml
│   │   ├── secrets-staging.yaml    # sops-encrypted
│   │   └── secrets-production.yaml # sops-encrypted
│   └── myapp/
│       ├── values.yaml
│       ├── values-staging.yaml
│       └── values-production.yaml

For larger teams, you can split by team or domain using Helmfile's helmfiles include directive:

# helmfile.yaml (root)
helmfiles:
  - path: teams/platform/helmfile.yaml
  - path: teams/backend/helmfile.yaml
  - path: teams/frontend/helmfile.yaml

Each sub-helmfile owns its own releases, and you can still run them all together from the root.

Environment Values and Layering

The environments/staging.yaml file sets variables that apply across all releases in that environment:

# environments/staging.yaml
clusterName: eks-staging-us-east-1
domain: staging.example.com
replicaCount: 1
storageClass: gp3

monitoring:
  enabled: true
  namespace: monitoring

ingress:
  className: nginx
  tlsEnabled: true

Then in a chart's values template, reference these:

# charts/myapp/values.yaml (base)
replicaCount: 1
ingress:
  enabled: true
  className: nginx

# charts/myapp/values-production.yaml (overrides)
replicaCount: 3
resources:
  requests:
    cpu: 500m
    memory: 512Mi
  limits:
    cpu: 2000m
    memory: 1Gi

Layering works as: base values → environment values → release-level set overrides. Later layers win.

Secrets Management with SOPS

Helmfile has native support for SOPS-encrypted secrets files. Install the helmfile-secrets plugin and you're good:

helm plugin install https://github.com/jkroepke/helm-secrets

Encrypt your secrets file:

sops --encrypt --pgp YOUR_GPG_KEY_ID \
  charts/postgresql/secrets-production.yaml > \
  charts/postgresql/secrets-production.yaml

Reference it in your helmfile:

releases:
  - name: postgresql
    secrets:
      - charts/postgresql/secrets-{{ .Environment.Name }}.yaml

Helmfile will decrypt on the fly when running. The decrypted values are never written to disk — they're passed directly to Helm in memory.

Core Commands

# Dry run — see what would change without applying
helmfile diff --environment staging

# Apply everything
helmfile sync --environment production

# Apply only a specific release
helmfile sync --environment production --selector name=myapp

# Apply only releases with a specific label
helmfile sync --environment production --selector tier=frontend

# Destroy a release
helmfile destroy --environment staging --selector name=myapp

# List all release states
helmfile list

# Fetch all charts without installing (useful in CI to pre-pull)
helmfile fetch

The diff command is your best friend before any production change. It shows exactly what Helm would change — both at the Helmfile level (which releases are added/removed) and at the Kubernetes manifest level (which objects change).

Release Dependencies with needs

The needs field is critical for releases that must start in a specific order:

releases:
  - name: postgresql
    namespace: data
    chart: bitnami/postgresql

  - name: redis
    namespace: data
    chart: bitnami/redis

  - name: myapp
    namespace: default
    chart: ./charts/myapp
    needs:
      - data/postgresql  # format: namespace/release-name
      - data/redis

Helmfile will wait for postgresql and redis to reach a healthy state before deploying myapp. This is much cleaner than sleep loops in shell scripts.

Labels for Selective Deployment

Labels are how you do partial deploys — useful when one team only wants to deploy their services:

releases:
  - name: myapp-api
    labels:
      app: myapp
      component: api
      tier: backend
    chart: ./charts/myapp-api

  - name: myapp-worker
    labels:
      app: myapp
      component: worker
      tier: backend
    chart: ./charts/myapp-worker

  - name: myapp-frontend
    labels:
      app: myapp
      component: frontend
      tier: frontend
    chart: ./charts/myapp-frontend
# Deploy only backend components
helmfile sync --selector tier=backend

# Deploy only myapp releases
helmfile sync --selector app=myapp

CI/CD Integration

A typical GitHub Actions workflow:

name: Deploy

on:
  push:
    branches: [main]

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

      - name: Install tools
        run: |
          curl -L https://github.com/helmfile/helmfile/releases/download/v0.163.1/helmfile_linux_amd64.tar.gz | tar xz
          sudo mv helmfile /usr/local/bin/
          helm plugin install https://github.com/jkroepke/helm-secrets

      - name: Configure kubectl
        uses: aws-actions/amazon-eks-update-kubeconfig@v1
        with:
          cluster-name: eks-production-us-east-1

      - name: Import GPG key for SOPS
        run: echo "${{ secrets.GPG_PRIVATE_KEY }}" | gpg --import

      - name: Diff (dry run)
        run: helmfile diff --environment production

      - name: Deploy
        run: helmfile sync --environment production

Comparing helmfile apply vs helmfile sync

This trips people up:

CommandBehavior
helmfile syncInstalls/upgrades all releases to match desired state
helmfile applyLike sync, but runs diff first and only proceeds if there are changes
helmfile diffShows changes without applying — exit code 2 if there are diffs

In CI, helmfile apply is usually what you want — it's idempotent and skips no-op deployments efficiently.

When to Use Helmfile vs Argo CD

Helmfile and Argo CD serve overlapping but different needs:

  • Helmfile is a CLI tool — you run it, it does the thing. Good for teams not ready for a full GitOps operator, or for bootstrapping a cluster before Argo CD itself is running.
  • Argo CD is a controller — it continuously reconciles state. Better for large teams who want drift detection and automatic sync.

Many teams use both: Helmfile to bootstrap Argo CD and cluster-level infrastructure, then Argo CD for application releases. The two tools don't conflict.

Helmfile is one of those tools that feels unnecessary until you're managing ten releases across three environments, and then you can't imagine going back to raw Helm commands.

Share:

Was this article helpful?

Amara Okafor
Amara Okafor

DevSecOps Lead

Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.

Related Articles

Discussion