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.yamldeclares every release in the stack — chart, version, namespace, values, secrets — instead of runninghelm upgrade --installper chart by hand. helmfile applyis diff-then-apply: it runshelmfile difffirst 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.yamllists 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 applyrunshelmfile difffirst 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.yamlitself is a Go template, so anything that varies by env, region, or cluster goes throughrequiredEnv,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 --installis 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 -kor 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:
- 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 tohelmfor each release. No agent, no controller, no CRDs. - 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 --installsucceeds; it doesn't watch anything afterwards. - The
helmfile.yamlis a Go template before it's a YAML file. Helmfile renders it first (resolvingrequiredEnv,exec, conditionals), then parses the result. The same is true for any*.gotmplvalues 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 yourdiffdiffers run to run. Pin to one version; bump intentionally.installed:— declarative uninstall. Flip tofalseandhelmfile applyremoves 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 likecertManager.enabledreferences the environment-values tree. If the path resolves tofalseor 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 tohelm install --set name=value. Usevalues: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 beforehelm upgrade --install. Use tokubectl applya CRD the chart expects but doesn't ship.postsync— afterhelm upgrade --installsucceeds. Use to smoke-test, notify, or register the rollout.cleanup— always, afterpostsyncor 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 apply—difffirst, thensynconly 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:
- Unpinned chart version (
version: "*"). execorenvreading a value that drifts (date,git rev-parse HEAD).- 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.