Skip to content

Helmfile — the 80/20 course for static Kubernetes clusters

Scope: install and upgrade a small stack of Helm charts onto an already-running Kubernetes cluster (EKS, GKE, AKS, on-prem, kind) from your laptop or a CI runner. Cluster provisioning, chart authoring, ArgoCD/Flux, and custom plugin development are intentionally out of scope — pointers at the end if you ever need them.

Read this top-to-bottom once, then come back to specific sections as reference. This module's glossary is the dictionary for any term you don't recognise here — it also seeds the Retain flashcards.


TL;DR — in 30 seconds:

  • One helmfile.yaml declares every release in the stack — chart, version, namespace, values, secrets — instead of running helm upgrade --install per chart by hand.
  • helmfile apply is diff-then-apply: it runs helmfile diff first and only upgrades releases whose diff is non-empty, so the default workflow is idempotent.
  • It's a push-mode CLI — you or CI trigger every run — not a pull-based GitOps controller reconciling continuously; that's ArgoCD/Flux's job.

1. Why Helmfile — and when not to use it

Helm gives you a packaged way to install one chart. Helmfile gives you a packaged way to install many charts together, version-pinned, with per-environment values, in the right order, idempotently. Same way docker-compose is to docker run.

What you get over plain Helm:

  • Declarative spec. One helmfile.yaml lists every release in the stack — chart, version, namespace, values, secrets. The file is the source of truth; the cluster is the consequence.
  • Diff-then-apply. helmfile apply runs helmfile diff first and only upgrades releases whose diff is non-empty. The default workflow is idempotent.
  • Environments. One file, multiple targets (dev, staging, prod). Switch with -e prod; the values and even the set of installed releases adapt.
  • Templating. The helmfile.yaml itself is a Go template, so anything that varies by env, region, or cluster goes through requiredEnv, env, exec, readFile.
  • Secrets in the repo. helm-secrets + SOPS lets you commit encrypted values files. Helmfile decrypts them in flight, never on disk.

When not to reach for Helmfile:

  • You only ever install one chart, never more, never in coordinated order → plain helm upgrade --install is fine.
  • You want a pull-based GitOps controller reconciling continuously → ArgoCD or Flux, not Helmfile. Helmfile is a push-mode CLI; you (or your CI) trigger every run.
  • You want to manage non-Helm Kubernetes objects (raw YAML, Kustomize) as first-class peers → reach for kubectl apply -k or one of the operators. Helmfile can call out via hooks, but that's not its strength.
  • You're on Helm 2 / Tiller → upgrade to Helm 3 first; Helmfile dropped Tiller support years ago.

The 80/20 sweet spot Helmfile nails: declarative, multi-chart deploys to a cluster you don't own the lifecycle of.


2. Mental model

+---------------------------+     reads      +------------------------+
|  Control machine          | -------------> |  helmfile.yaml         |
|  (laptop / CI runner)     |                |  + values files        |
|                           |                |  + secrets (SOPS)      |
|  - helmfile CLI           |                |  + bases / sub-files   |
|  - helm CLI               |                +------------------------+
|  - helm-diff plugin       |
|  - helm-secrets plugin    |     calls      +------------------------+
|  - SOPS + age/KMS         | -------------> |  helm upgrade --install|
|  - kubeconfig             |                |  per release           |
+---------------------------+                +-----------+------------+
                                                         |
                                                         v
                                              +----------------------+
                                              |  Kubernetes cluster  |
                                              |  Helm release state  |
                                              |  Workloads           |
                                              +----------------------+

Three things to internalise:

  1. Helmfile is glue, not a daemon. It reads your state file, renders templates on the control machine, decrypts secrets through helm-secrets, then shells out to helm for each release. No agent, no controller, no CRDs.
  2. Helm still owns the cluster-side state. Each release is tracked by Helm in a Secret in its namespace. Helmfile's job ends when helm upgrade --install succeeds; it doesn't watch anything afterwards.
  3. The helmfile.yaml is a Go template before it's a YAML file. Helmfile renders it first (resolving requiredEnv, exec, conditionals), then parses the result. The same is true for any *.gotmpl values file.

3. Setup on the control machine

You need four binaries plus three plugins. None of them go on the cluster.

# kubectl — the cluster client. Use the version that matches your cluster.
brew install kubectl

# Helm — Helmfile's underlying executor.
brew install helm

# Helmfile — the orchestrator. v1.0+ (released 2025) is the current line; older 0.x pins (e.g. 0.162.0) still work, same 80/20 surface.
brew install helmfile           # or: go install github.com/helmfile/helmfile@latest

# SOPS — used by helm-secrets for encrypted values.
brew install sops
brew install age                # the simplest SOPS backend

# Helm plugins — Helmfile depends on these; install them once.
helmfile init                   # interactive; installs helm-diff and helm-secrets
# Equivalent manual install:
helm plugin install https://github.com/databus23/helm-diff
helm plugin install https://github.com/jkroepke/helm-secrets
helm plugin install https://github.com/aslafy-z/helm-git    # only if you use git: chart URLs

helmfile init is idempotent; re-run it any time after you change your helmfile.yaml to be sure the plugins are present (CI runners forget). It also checks that kubectl can reach the current context.

Verify your kubeconfig points at the cluster you actually want to deploy to. Helmfile uses the current context unless helmDefaults.kubeContext says otherwise:

kubectl config current-context           # see what you're about to touch
kubectl config get-contexts              # list every known context
kubectl config use-context staging       # switch

Your repo layout will look like this — keep it small:

deploy/
├── helmfile.yaml                  # top-level state
├── environments/
│   ├── default.yaml
│   ├── staging.yaml
│   └── prod.yaml
├── releases/
│   ├── ingress-nginx.yaml         # per-release values
│   ├── cert-manager.yaml
│   └── demo-app.yaml
├── secrets/
│   ├── staging.yaml               # SOPS-encrypted
│   └── prod.yaml                  # SOPS-encrypted
└── charts/
    └── demo-app/                  # optional local chart

4. The minimum viable helmfile.yaml

The smallest file that gets you something useful:

repositories:
  - name: ingress-nginx
    url: https://kubernetes.github.io/ingress-nginx

releases:
  - name: ingress-nginx
    namespace: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.10.0
    createNamespace: true
    values:
      - controller:
          replicaCount: 2

Run it:

helmfile diff           # show what would change
helmfile apply          # apply if non-empty diff
helmfile list           # show resolved releases

That's the whole loop. Everything else — environments, secrets, sub-helmfiles — is composition on top of these two top-level keys (repositories, releases).

A second run of helmfile apply against an unchanged file is a no-op. That's the idempotency contract.


5. Repositories — where charts come from

Four sources, in order of how often you'll see them:

1. Classic Helm repository. An HTTPS URL serving index.yaml plus .tgz files. Add it under repositories: and reference charts as <alias>/<chart>:

repositories:
  - name: jetstack
    url: https://charts.jetstack.io
  - name: ingress-nginx
    url: https://kubernetes.github.io/ingress-nginx
  - name: bitnami
    url: https://charts.bitnami.com/bitnami

releases:
  - name: cert-manager
    namespace: cert-manager
    chart: jetstack/cert-manager
    version: v1.14.5

2. OCI registry. Helm 3.8+ resolves OCI URLs directly. No repositories: entry needed; you just put the URL in chart::

releases:
  - name: argo-workflows
    namespace: argo
    chart: oci://ghcr.io/argoproj/argo-helm/argo-workflows
    version: 0.41.0

For private registries you authenticate to Helm once (helm registry login) and Helmfile inherits the credentials.

3. Local chart. A filesystem path relative to the helmfile.yaml:

releases:
  - name: demo-app
    namespace: demo
    chart: ./charts/demo-app
    version: 0.1.0

Useful when the chart lives in the same repo as the deploy spec. Combine with helmfile deps to run helm dependency update on the local chart before installing.

4. Git chart source (via helm-git). For charts that are only ever published as git refs:

releases:
  - name: external-thing
    chart: git+https://github.com/example/charts@charts/thing?ref=v1.2.3

Helmfile passes this to Helm, which passes it to the helm-git plugin, which clones, checks out the ref, and exposes the chart at the requested path. Use sparingly — git charts are slow to fetch in CI.

The first thing your CI should run, before any diff, is helmfile repos to make sure every classic repo is registered and updated.


6. Releases — the core unit

A release entry is one Helm release. The fields you'll actually use:

releases:
  - name: cert-manager                 # required — Helm release name
    namespace: cert-manager            # required — target namespace
    createNamespace: true              # let Helmfile run `kubectl create ns` if missing
    chart: jetstack/cert-manager       # required — chart reference
    version: v1.14.5                   # pin to exact version (do this)
    installed: true                    # default true; flip to false to uninstall
    condition: certManager.enabled     # toggle from environment values
    labels:
      tier: platform
      app: cert-manager
    needs:
      - ingress-nginx/ingress-nginx    # `namespace/name` of dependencies
    values:
      - releases/cert-manager.yaml
      - installCRDs: true              # inline override
    secrets:
      - secrets/cert-manager.yaml      # SOPS-encrypted
    set:
      - name: global.logLevel
        value: 2
    hooks:
      - events: ["presync"]
        showlogs: true
        command: ./scripts/wait-for-webhooks.sh

The fields worth dwelling on:

  • version: — always pin. A SemVer range (^1.14.0) means "whatever's newest at run time", which means your diff differs run to run. Pin to one version; bump intentionally.
  • installed: — declarative uninstall. Flip to false and helmfile apply removes the release. Removing the entry entirely doesn't uninstall — that's intentional, to protect against accidental file edits.
  • condition: — environment-driven on/off. A string like certManager.enabled references the environment-values tree. If the path resolves to false or is missing, the release is skipped (not uninstalled — just ignored).
  • labels: — for selectors later. Free-form key/value pairs. Pick a convention and stick to it; tier-based labels (tier: platform/app/observability) cover most cases.
  • needs: — explicit ordering. Lists releases that must finish before this one starts. Helmfile builds a DAG; cycles are a hard error.
  • set: — last-resort override. Equivalent to helm install --set name=value. Use values: for anything more than one or two scalars; set: doesn't handle nested structures well.

7. Values — configuring releases

Most of your time will be spent in values files, not in helmfile.yaml. Two ways to attach values to a release:

releases:
  - name: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.10.0
    values:
      - releases/ingress-nginx.yaml          # file
      - controller:                          # inline
          service:
            externalTrafficPolicy: Local

releases/ingress-nginx.yaml:

controller:
  replicaCount: 2
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
  metrics:
    enabled: true

When the same key appears in multiple entries, Helmfile applies strategic merge: scalars and lists are replaced in order, maps are merged recursively. So in the example above, controller.replicaCount (from the file) and controller.service.externalTrafficPolicy (inline) coexist; if both set controller.replicaCount, the later one wins.

Two patterns that come up:

Templated values files (*.yaml.gotmpl). Helmfile renders the file as a Go template before passing it to Helm. Anything you'd want to compute at deploy time goes here:

releases/ingress-nginx.yaml.gotmpl:

controller:
  replicaCount: {{ .Environment.Values.ingressReplicaCount | default 2 }}
  service:
    annotations:
      external-dns.alpha.kubernetes.io/hostname: ingress.{{ .Environment.Values.domain }}

Then reference it the normal way:

values:
  - releases/ingress-nginx.yaml.gotmpl

Per-environment values without separate files. When the per-env delta is small, branch inline:

values:
  - releases/ingress-nginx.yaml
  - {{ if eq .Environment.Name "prod" }}
    controller:
      replicaCount: 4
    {{ end }}

Both work; one stays readable when the delta is two lines, the other when it's twenty.


8. Environments — multi-stage deployments

Environments turn one helmfile.yaml into a multi-target tool. Declare them at the top:

environments:
  default:
    values:
      - environments/default.yaml
  staging:
    values:
      - environments/default.yaml
      - environments/staging.yaml
    secrets:
      - secrets/staging.yaml
  prod:
    values:
      - environments/default.yaml
      - environments/prod.yaml
    secrets:
      - secrets/prod.yaml

environments/default.yaml:

domain: example.com
certManager:
  enabled: true
ingressReplicaCount: 2

environments/prod.yaml:

domain: prod.example.com
ingressReplicaCount: 4

Pick the environment at the CLI:

helmfile -e prod apply
helmfile -e staging diff
helmfile apply                # uses `default` environment

The values you put under environments.<name>.values are not Helm values — they're environment values, available everywhere as .Environment.Values. Use them to drive per-env conditions (condition: certManager.enabled), per-env Helm values (templated .gotmpl), or per-env hook arguments.

requiredEnv ties this to CI:

environments:
  prod:
    values:
      - environments/prod.yaml
      - awsAccountId: {{ requiredEnv "AWS_ACCOUNT_ID" }}

A helmfile apply against prod without AWS_ACCOUNT_ID set now fails at template-render time, before any cluster call.


9. Templating — Go templates inside Helmfile

The helmfile.yaml, every *.gotmpl values file, and most string-typed fields go through Go's text/template plus Sprig functions plus Helmfile's extras.

The functions you'll actually use:

Function What it does
requiredEnv "VAR" Read env var; fail render if empty.
env "VAR" Read env var; empty string if unset.
exec "/bin" (list ...) Shell out; substitute stdout.
readFile "path" Inline a file's contents.
default x .Values.y Use x when .Values.y is missing or empty.
toYaml Re-serialise a value as YAML (pair with nindent N).
nindent N New-line then N spaces of indentation.
eq / ne / or / and The standard conditionals.
printf String formatting.
quote Wrap a string in double quotes.

The variables every template sees:

.Values            — merged Helm values (rarely used at helmfile.yaml level)
.Environment       — the selected environment
.Environment.Name  — its name
.Environment.Values — the environment values tree
.Release           — inside *.gotmpl release values: the release entry
.StateValues       — the helmfile state values
.Namespace         — the selected namespace (CLI --namespace)

A common pattern: a values file that branches on environment without separate files:

controller:
  replicaCount: {{ if eq .Environment.Name "prod" }}4{{ else }}2{{ end }}
  resources:
    {{- if eq .Environment.Name "prod" }}
    requests:
      cpu: 500m
      memory: 512Mi
    {{- else }}
    requests:
      cpu: 100m
      memory: 128Mi
    {{- end }}

The {{- and -}} variants strip surrounding whitespace; use them inside YAML so the rendered output stays valid.

For anything heavier, branch the file itself with two .gotmpls and let the env-values pick.


10. Secrets — helm-secrets + SOPS

The default secret model is "encrypted file in the repo, decrypted in flight". helm-secrets does the integration; SOPS does the encryption.

One-time setup with age (laptop-only / small team):

age-keygen -o ~/.config/sops/age/keys.txt
# Reads: AGE-SECRET-KEY-1...
# Public key (write this in .sops.yaml): age1...

cat > .sops.yaml <<'EOF'
creation_rules:
  - path_regex: secrets/.*\.yaml$
    age: age1abc...
EOF

Encrypt a file:

cat > secrets/prod.yaml <<'EOF'
db:
  password: hunter2
  url: postgres://prod.example/app
EOF
sops --encrypt --in-place secrets/prod.yaml

The file is still YAML-shaped; only the leaf values are ciphertext. Diffs of secret rotations stay legible.

Reference it from helmfile.yaml:

releases:
  - name: demo-app
    chart: ./charts/demo-app
    version: 0.1.0
    values:
      - releases/demo-app.yaml
    secrets:
      - secrets/prod.yaml

Or from an environment:

environments:
  prod:
    values:
      - environments/prod.yaml
    secrets:
      - secrets/prod.yaml

At run time, helmfile apply calls helm-secrets, which calls sops, which uses your age key (or KMS, or PGP) to decrypt. The plaintext goes to Helm in memory — never to disk.

Hiding secrets from logs:

helmfile -e prod diff --suppress-secrets
helmfile -e prod apply --suppress-secrets

--suppress-secrets masks any value that came from a secrets: entry in the diff output. In CI, this is non-negotiable.

Cloud-backed keys (KMS):

# .sops.yaml
creation_rules:
  - path_regex: secrets/prod.*\.yaml$
    kms: arn:aws:kms:eu-west-1:123:key/abc
  - path_regex: secrets/staging.*\.yaml$
    kms: arn:aws:kms:eu-west-1:123:key/def

Now decryption authorisation is the IAM role of whoever (CI runner, operator) is running Helmfile. No private key on anyone's laptop.


11. Composition — helmDefaults, bases, helmfiles

Once a helmfile.yaml grows past 5–10 releases or 2 environments, you split.

helmDefaults — defaults for every release in the file.

helmDefaults:
  wait: true                          # wait for resources to be ready
  timeout: 600                        # seconds
  createNamespace: true               # auto-create missing namespaces
  recreatePods: false
  kubeContext: ""                     # leave empty to use current context
  args:
    - "--debug"

Any release that sets the same field overrides the default. Keep this short — five fields max — so readers don't have to guess.

bases — shared blocks across helmfile files.

bases/common.yaml:

environments:
  default:
    values:
      - ../environments/default.yaml
  staging:
    values:
      - ../environments/default.yaml
      - ../environments/staging.yaml
  prod:
    values:
      - ../environments/default.yaml
      - ../environments/prod.yaml
    secrets:
      - ../secrets/prod.yaml
helmDefaults:
  wait: true
  timeout: 600

Then in each helmfile.yaml:

bases:
  - ../bases/common.yaml

releases:
  - name: cert-manager
    ...

bases: is parse-time merge — the file is concatenated before YAML parsing — so you can't compute base names at runtime. Use it for the things that are truly constant across helmfiles.

helmfiles — sub-states.

Top-level deploy/helmfile.yaml:

helmfiles:
  - path: platform/helmfile.yaml
  - path: apps/helmfile.yaml
    selectorsInherited: true

This composes two sub-states. helmfile -e prod apply runs both in order: platform first, apps second. Each sub-state can declare its own repositories, releases, environments (or import them via bases:).

flowchart LR
    Top["deploy/helmfile.yaml<br/>(top-level state)"]
    Platform["platform/helmfile.yaml<br/>(sub-state, runs 1st)"]
    Apps["apps/helmfile.yaml<br/>(sub-state, runs 2nd)"]

    Top -->|"helmfiles:"| Platform
    Top -->|"helmfiles:"| Apps
    Platform -.->|"runs before"| Apps

Two flavours of composition you can mix:

  • helmfiles: for breadth (different concerns: platform vs apps, region A vs region B).
  • bases: for shared definitions that every sub-state needs.

Don't deep-nest helmfiles. Two levels is fine; four levels is a maze.


12. Selectors, labels, needs — partial deploys and ordering

The default helmfile apply runs every release. In practice you want to operate on subsets.

Labels and selectors. Tag each release with what it is:

releases:
  - name: ingress-nginx
    labels:
      tier: platform
      app: ingress
  - name: cert-manager
    labels:
      tier: platform
      app: cert-manager
  - name: demo-app
    labels:
      tier: app
      app: demo

Then pick a subset:

helmfile -l tier=platform apply           # platform only
helmfile -l tier=app apply                # apps only
helmfile -l 'app=demo' apply              # one release
helmfile -l 'tier=platform,app!=ingress' diff   # composite

-l is the short form of --selector. Multiple -l flags are OR'd; comma-separated selectors inside one -l are AND'd.

needs — explicit dependencies. A release with needs: waits for the named releases to finish before it runs:

releases:
  - name: ingress-nginx
    namespace: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.10.0

  - name: cert-manager
    namespace: cert-manager
    chart: jetstack/cert-manager
    version: v1.14.5
    needs:
      - ingress-nginx/ingress-nginx        # `namespace/name`

  - name: demo-app
    namespace: demo
    chart: ./charts/demo-app
    needs:
      - cert-manager/cert-manager

Helmfile builds the DAG, runs independent releases in parallel (up to --concurrency), and gives you --skip-needs to iterate without re-running dependencies. The needs: fields above describe this graph — a chain here, but independent branches run concurrently:

graph LR
    A[ingress-nginx] --> B[cert-manager]
    B --> C[demo-app]

Limit and concurrency.

helmfile -l app=demo --skip-needs apply       # just demo, don't re-run deps
helmfile --concurrency 2 apply                # cap parallel installs at 2

--include-needs for safety. When you select demo-app but want its needs: chain included automatically:

helmfile -l app=demo --include-needs apply

13. Hooks — pre / post sync

Helmfile hooks are commands run on the control machine around a release's lifecycle. They're distinct from Helm's chart hooks (which run as Jobs inside the cluster).

Four events, in order:

releases:
  - name: cert-manager
    chart: jetstack/cert-manager
    version: v1.14.5
    hooks:
      - events: ["prepare"]
        showlogs: true
        command: ./scripts/fetch-extra-files.sh
      - events: ["presync"]
        command: kubectl
        args: ["apply", "-f", "manifests/crds/"]
      - events: ["postsync"]
        command: ./scripts/wait-for-webhooks.sh
      - events: ["cleanup"]
        command: rm
        args: ["-f", ".tmp-manifests"]

When each fires:

  • prepare — before chart resolution. Use to pull a chart on disk, or set environment for the upcoming helm call.
  • presync — just before helm upgrade --install. Use to kubectl apply a CRD the chart expects but doesn't ship.
  • postsync — after helm upgrade --install succeeds. Use to smoke-test, notify, or register the rollout.
  • cleanup — always, after postsync or failure. Use to release locks or delete temp files.

Hook commands are templated: args: items go through Go templates with the full context, so you can pass {{ .Environment.Name }} or {{ .Release.Namespace }}.

Rule of thumb: if the action belongs in the cluster, use a Helm chart hook (Job). If it belongs on the operator's machine (or CI), use a Helmfile hook.


14. The everyday workflow

# 0. Make sure plugins exist and repos are fresh
helmfile init                                # idempotent plugin install
helmfile repos                               # helm repo add + helm repo update

# 1. See what would change
helmfile -e prod diff

# 2. Apply (diff + sync; only upgrades releases with a non-empty diff)
helmfile -e prod apply

# 3. Verify
helmfile -e prod list
kubectl -n cert-manager get pods

# 4. Iterate on one release
helmfile -e prod -l app=demo --skip-needs diff
helmfile -e prod -l app=demo --skip-needs apply

# 5. Roll back
# Helmfile doesn't have its own rollback; use helm directly.
helm -n demo history demo-app
helm -n demo rollback demo-app <REVISION>

The crucial distinction:

  • helmfile sync — install/upgrade every selected release unconditionally. Fast, but doesn't tell you what changed. Use when you trust the file (e.g. immediately after a successful diff in CI).
  • helmfile applydiff first, then sync only the changed releases. The everyday command.
  • helmfile diff — print the diff against the live cluster. Doesn't change anything.

apply is idempotent in the safest sense: a second apply against an unchanged file is a no-op (zero diffs, zero helm upgrade calls). If a second apply isn't a no-op, something rendered differently, which almost always means an unpinned chart version or exec/env reading a moving target.


15. Helmfile commands — the daily set

Command Use for
helmfile init Install required Helm plugins.
helmfile repos helm repo add + helm repo update for every repositories: entry.
helmfile deps helm dependency update for every local chart with Chart.yaml deps.
helmfile diff Print the diff between rendered manifests and live cluster state.
helmfile sync Install/upgrade every selected release unconditionally.
helmfile apply Diff first; sync only releases with a non-empty diff. Use this most.
helmfile template Render manifests to stdout (or a directory). Never touches the cluster.
helmfile destroy helm uninstall every selected release.
helmfile lint helm lint every selected release after rendering.
helmfile list Print resolved releases — name, namespace, chart, version, enabled.
helmfile write-values Compute merged values for a release and write them to a file.
helmfile status helm status for every selected release.
helmfile test Run helm test for releases that have test hooks.

Flags worth memorising:

-e ENV                  # pick environment (default: `default`)
-l label=value          # selector; repeatable
-f FILE                 # use this helmfile.yaml instead of the default
--selector key=val      # long form of -l
--concurrency N         # parallelism cap (default: unlimited)
--skip-needs            # ignore `needs:` for this run
--include-needs         # include needs of selected releases
--include-transitive-needs  # include the whole upstream chain
--skip-deps             # don't run `helm dependency update`
--suppress-secrets      # mask secrets in diff output
--dry-run               # pass --dry-run to helm
--debug                 # print every helm command Helmfile invokes
--state-values-set k=v  # override state values from the CLI
--state-values-file f   # extra state values file

16. Patterns for static clusters

Things specific to running Helmfile against an existing managed cluster (EKS, GKE, AKS).

Kubeconfig and contexts. Set the context explicitly in helmDefaults if your kubeconfig has many entries and you don't want to rely on kubectl config use-context:

helmDefaults:
  kubeContext: arn:aws:eks:eu-west-1:123456789012:cluster/hiro-prod

In CI, populate ~/.kube/config from a secret and let the run pick the only context present. Don't trust the runner's default context.

Namespace creation. Set createNamespace: true either per release or as a default. Beware of cluster admission webhooks that block dynamic namespace creation; in that case, bootstrap namespaces with a separate kubectl apply -f namespaces.yaml or with a presync hook on a sentinel release.

Chart version pinning at scale. As your stack grows, you'll want a renovate-bot or dependabot config that bumps version: fields. Helmfile doesn't carry its own bump tooling; treat it like any other YAML.

Idempotency check. Second run of helmfile apply after a successful one should produce zero diffs. If it doesn't, your most likely culprits are:

  1. Unpinned chart version (version: "*").
  2. exec or env reading a value that drifts (date, git rev-parse HEAD).
  3. The chart itself has non-deterministic outputs (random secret generation in templates). Pin the secret with explicit values; don't let charts generate them.

Drift detection. A daily helmfile diff --no-color in CI, piped to a notifier on non-empty output, catches manual cluster edits before they accumulate.

Concurrency and rate limits. Default --concurrency is unlimited. For ≤10 releases this is fine. For larger states or rate-limited clusters (EKS at boot, GKE during a control-plane upgrade), cap at --concurrency 3 or 5.

helmfile template for CI gates. Render the full stack and pipe through your manifest-policy tool (Conftest, Kyverno-CLI, Datree):

helmfile -e prod template > out.yaml
conftest test out.yaml --policy policies/

This catches policy violations before they reach the cluster.

Logging the run. Tee Helmfile output and the underlying helm commands:

helmfile -e prod --debug apply 2>&1 | tee logs/$(date +%Y-%m-%d_%H%M%S).log

Add logs/ to .gitignore.


17. Worked example — ingress-nginx + cert-manager + a demo app

End-to-end. One state file, three environments, two real charts, one local chart.

deploy/
├── helmfile.yaml
├── environments/
│   ├── default.yaml
│   ├── staging.yaml
│   └── prod.yaml
├── releases/
│   ├── ingress-nginx.yaml.gotmpl
│   ├── cert-manager.yaml
│   └── demo-app.yaml.gotmpl
├── secrets/
│   ├── staging.yaml          # SOPS-encrypted
│   └── prod.yaml             # SOPS-encrypted
└── charts/
    └── demo-app/
        ├── Chart.yaml
        ├── values.yaml
        └── templates/
            ├── deployment.yaml
            ├── service.yaml
            └── ingress.yaml

deploy/helmfile.yaml:

repositories:
  - name: ingress-nginx
    url: https://kubernetes.github.io/ingress-nginx
  - name: jetstack
    url: https://charts.jetstack.io

environments:
  default:
    values:
      - environments/default.yaml
  staging:
    values:
      - environments/default.yaml
      - environments/staging.yaml
    secrets:
      - secrets/staging.yaml
  prod:
    values:
      - environments/default.yaml
      - environments/prod.yaml
    secrets:
      - secrets/prod.yaml

helmDefaults:
  wait: true
  timeout: 600
  createNamespace: true

releases:
  - name: ingress-nginx
    namespace: ingress-nginx
    chart: ingress-nginx/ingress-nginx
    version: 4.10.0
    labels:
      tier: platform
      app: ingress-nginx
    values:
      - releases/ingress-nginx.yaml.gotmpl

  - name: cert-manager
    namespace: cert-manager
    chart: jetstack/cert-manager
    version: v1.14.5
    labels:
      tier: platform
      app: cert-manager
    needs:
      - ingress-nginx/ingress-nginx
    values:
      - releases/cert-manager.yaml
      - installCRDs: true

  - name: demo-app
    namespace: demo
    chart: ./charts/demo-app
    version: 0.1.0
    labels:
      tier: app
      app: demo
    needs:
      - cert-manager/cert-manager
    values:
      - releases/demo-app.yaml.gotmpl
    secrets:
      - secrets/{{ .Environment.Name }}.yaml
    hooks:
      - events: ["postsync"]
        showlogs: true
        command: ./scripts/smoke-test.sh
        args:
          - "https://demo.{{ .Environment.Values.domain }}/healthz"

deploy/environments/default.yaml:

domain: example.com
ingressReplicaCount: 2
demoReplicaCount: 1
clusterIssuerEmail: ops@example.com

deploy/environments/prod.yaml:

domain: prod.example.com
ingressReplicaCount: 4
demoReplicaCount: 3

deploy/releases/ingress-nginx.yaml.gotmpl:

controller:
  replicaCount: {{ .Environment.Values.ingressReplicaCount }}
  service:
    externalTrafficPolicy: Local
  metrics:
    enabled: true
  resources:
    requests:
      cpu: 100m
      memory: 128Mi

deploy/releases/cert-manager.yaml:

prometheus:
  enabled: true

deploy/releases/demo-app.yaml.gotmpl:

image:
  repository: ghcr.io/example/demo-app
  tag: {{ requiredEnv "DEMO_APP_TAG" }}
replicaCount: {{ .Environment.Values.demoReplicaCount }}
ingress:
  enabled: true
  host: demo.{{ .Environment.Values.domain }}
  tls:
    enabled: true
    issuerEmail: {{ .Environment.Values.clusterIssuerEmail }}

deploy/secrets/prod.yaml (after sops --encrypt --in-place):

db:
  password: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
  url: ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]

Run order, with verification at each step:

# 0. Plugin + repo bootstrap
helmfile init
helmfile -e prod repos

# 1. Static check: render and lint (no cluster contact)
helmfile -e prod lint

# 2. Preview the diff against the cluster
helmfile -e prod diff --suppress-secrets

# 3. Apply
DEMO_APP_TAG=v1.2.3 helmfile -e prod apply --suppress-secrets

# 4. Idempotency check — second apply should be all "no diff"
DEMO_APP_TAG=v1.2.3 helmfile -e prod apply --suppress-secrets

# 5. Iterate on one release
DEMO_APP_TAG=v1.2.4 helmfile -e prod -l app=demo --skip-needs diff
DEMO_APP_TAG=v1.2.4 helmfile -e prod -l app=demo --skip-needs apply

# 6. Inspect
helmfile -e prod list
helmfile -e prod status
kubectl -n demo get pods,svc,ingress

# 7. Decommission demo (if needed)
helmfile -e prod -l app=demo destroy

When that whole pipeline behaves, you have the 80%. Everything else (custom plugins, operator-mode runs, ArgoCD integration) is layered on top of these building blocks.


18. What's intentionally out of scope here

Topic Pointer when you need it
Cluster provisioning Terraform (terraform-aws-eks), eksctl, kops. Provision once; let Helmfile own what runs inside.
Helm chart authoring helm create, the chart developer guide. Different mental model; only relevant if you publish charts.
ArgoCD / Flux GitOps controllers that pull from a repo and reconcile continuously. Reach for them when your team grows past push-mode CI.
Helmfile operator mode Running Helmfile inside the cluster as a controller. Niche; default is the CLI on a control machine.
Custom Helmfile plugins (Go) Write Go plugins that extend Helmfile. The plugin SDK exists; you almost never need it.
Kustomize releases chartify (a Helm plugin) wraps Kustomize bases as charts. Useful when you need to mix Kustomize and Helm in one Helmfile.
vals for inline secret fetch vals fetches secrets from external stores (Vault, SSM) at template time. Use when SOPS doesn't fit.
Multi-cluster orchestration Helmfile only talks to one context per run. Multi-cluster = multiple Helmfile invocations (different kubeContext per env).
Long-running cluster operations Helm timeouts are per-release. For things that take >10 min, use Helm chart hooks (Jobs) and let Helmfile wait.
State backup Helm release secrets are the cluster's record. Back them up like any other Kubernetes Secret.
Chart provenance / signing helm verify, cosign. Out of the everyday loop; relevant when your supply chain is regulated.
Windows targets Helmfile binaries exist on Windows; the workflow is identical. Out of focus here.

19. Reference card — daily commands

# Sanity
helmfile init                                # install/refresh helm-diff + helm-secrets
helmfile -e prod repos                       # helm repo add + update
helmfile -e prod list                        # what would be acted on
helmfile -e prod lint                        # render + helm lint

# Preview / apply
helmfile -e prod diff
helmfile -e prod apply
helmfile -e prod apply --suppress-secrets    # in CI, always

# Selection
helmfile -e prod -l tier=platform apply
helmfile -e prod -l app=demo --skip-needs apply
helmfile -e prod -l tier=app --include-needs diff

# Render / inspect
helmfile -e prod template > out.yaml
helmfile -e prod status
helmfile -e prod write-values --output-dir ./resolved

# Teardown
helmfile -e prod -l app=demo destroy

# Secrets
sops --encrypt --in-place secrets/prod.yaml
sops secrets/prod.yaml                       # decrypt-in-editor, re-encrypt on save
helmfile -e prod diff --suppress-secrets

# Debug
helmfile -e prod --debug apply 2>&1 | tee /tmp/helmfile.log
helmfile -e prod -l app=demo --skip-needs diff --debug

That's the 80%. Go run helmfile init && helmfile -e staging diff against your cluster.


20. How this connects

  • Helmfile installs Helm charts onto whatever a Kubernetes cluster (module 04, Kubernetes & Helm) already exposes — ConfigMaps, Secrets, and RBAC bindings are the objects landing on the other end of every helmfile apply; see Config & Secrets for how a chart's rendered values actually surface inside the cluster.
  • The diff-then-apply idempotency contract in §4 and §14 is the same shape as Terraform's plan-then-apply (module 07, Terraform, State): both refuse to touch anything until a diff against real state says something changed.
  • The three deep-dive pages that follow this overview — Environments & values layering, Secrets with SOPS, and The release DAG — expand §7–§10 and §12 into their own submodules; start there for the mechanics this page only sketches.