Skip to content

Environments & values layering

You set ingressReplicaCount: 4 in environments/prod.yaml, deploy, and nothing changes on the cluster. That's not a bug — it's a layering mistake, and it's the single most common way to lose an hour with Helmfile. The course page shows environments: as three blocks (default, staging, prod), each listing a values: file or two; this page slows down on how that layering actually resolves — the merge order, what counts as an "environment value" versus a "Helm value", and the failure modes that show up once a stack has more than one or two environments.

TL;DR — in 30 seconds:

  • Environment values (.Environment.Values) and Helm/release values (.Values inside the chart) are two separate trees — setting a key in environments/prod.yaml does nothing until a templated values file reads .Environment.Values and passes it into the chart.
  • Both environments.<name>.values and environments.<name>.secrets are lists merged in order, later entries winning — the same strategic-merge rule release values use.
  • Picking -e staging vs -e prod can change which releases run (via condition:), not just their config — check condition: before assuming a values typo.

1. Two separate value systems, easy to conflate

Helmfile has two value trees that look similar in YAML but serve different purposes:

Tree Declared under Consumed as Purpose
Environment values environments.<name>.values .Environment.Values.* in templates Deploy-time knobs: which env, what domain, what replica count — never sent to a chart directly
Helm (release) values a release's values: list merged into the chart's values.yaml What the chart itself receives — becomes .Values.* inside the chart's templates

The course page's environments/default.yaml (domain, certManager.enabled, ingressReplicaCount) is the first tree. It only becomes useful once something reads .Environment.Values.domain — usually inside a release's templated *.yaml.gotmpl values file, which is where the two trees actually connect.

flowchart LR
    EnvFiles["environments.<name>.values<br/>(environment values)"]
    EnvCtx[".Environment.Values.*<br/>available in every template"]
    Gotmpl["release values/*.gotmpl<br/>reads .Environment.Values"]
    Rendered["Rendered Helm values"]
    Chart["Chart's values.yaml<br/>(.Values.* inside the chart)"]

    EnvFiles --> EnvCtx --> Gotmpl --> Rendered --> Chart

That's the mistake from the top of this page, precisely: setting ingressReplicaCount: 4 in environments/prod.yaml does nothing to the cluster until some *.gotmpl values file actually reads .Environment.Values.ingressReplicaCount and assigns it to a chart field the chart understands (controller.replicaCount). Confusing the two trees is the single most common layering mistake.

2. Environment-values layering is additive, in list order

environments.<name>.values is a list, not a single file, and Helmfile merges every entry in the list — earlier entries first, later entries winning on conflicting keys:

environments:
  staging:
    values:
      - environments/default.yaml     # base: applies everywhere
      - environments/staging.yaml     # overrides: only what staging changes

Same strategic merge rule as release values (scalars/lists replace, maps merge recursively) applies here. This is why environments/staging.yaml in the course page only needs to contain the handful of keys that actually differ from default.yaml — everything else falls through from the base file. A staging override that repeats an unchanged key isn't wrong, just redundant; a common house style is: base file has every key with its most common value, every non-default environment file has only its deltas.

3. Secrets layer the same way, in a separate list

environments.<name>.secrets merges exactly like values, but through helm-secrets/SOPS decryption first — so an environment can combine a plaintext base with an encrypted override:

environments:
  prod:
    values:
      - environments/default.yaml
      - environments/prod.yaml
    secrets:
      - secrets/prod.yaml            # decrypted at run time, merged in after values

secrets: entries are conceptually a second values list decrypted just before use — they merge into the same .Environment.Values tree as values:, not a separate namespace. A key set in both values: and secrets: follows the same last-one-wins rule, decided by which list position comes later.

4. Selecting an environment doesn't just swap values — it can swap the release set

Because condition: on a release reads .Environment.Values, picking -e staging vs -e prod can change which releases run, not just their configuration:

releases:
  - name: cert-manager
    condition: certManager.enabled     # environments/staging.yaml may set this false

This is the layering trap worth naming explicitly: two environments can diff not only on replica counts but on the shape of the deployed stack itself. When triaging "why isn't X running in staging", check condition: before assuming a values typo.

5. Precedence, top to bottom

Putting the pieces from this page together, here is everything that can set a value that ends up inside a chart, lowest priority first:

# Source Scope
1 Chart's own values.yaml defaults Whatever the chart author shipped
2 environments.<name>.values (base file, e.g. default.yaml) Broadest environment layer
3 environments.<name>.values (env-specific file, e.g. prod.yaml) Narrower — later in the list
4 environments.<name>.secrets Decrypted, merged after plaintext values
5 Release values: list (files, then inline maps, in list order) Per-release, still .Environment.Values-aware if templated
6 Release set: Last-resort scalar override, highest priority
flowchart BT
    L1["1 · chart values.yaml"]
    L2["2 · environment base values"]
    L3["3 · environment override values"]
    L4["4 · environment secrets"]
    L5["5 · release values: list"]
    L6["6 · release set:"]

    L1 --> L2 --> L3 --> L4 --> L5 --> L6

    style L6 stroke-width:3px

6. How this connects

  • The precedence ladder in §5 is the same shape as two other modules' layering pages: Ansible's host_vars/group_vars ladder (module 08, Ansible, inventory & variables) and mise's config hierarchy (module 11, mise) — different sources, same rule of "later, more specific layer wins."
  • §3's secrets: layering is only the merge-order half of the story — what SOPS actually encrypts and how helm-secrets decrypts it in flight is this course's next page, Secrets with SOPS.
  • Once a release's rendered values reach the chart, what the chart does with them (mounting as env vars vs. a projected volume, Secret immutability) is Kubernetes' Config & Secrets page (module 04, Kubernetes & Helm) — this page's job stops at producing .Values, that page picks up from there.

Common gotchas

  • Setting a value only in environments/prod.yaml and expecting the chart to pick it up. Fix: it must be read via .Environment.Values.<key> inside a templated release values file and mapped to a field the chart understands — the environment tree never reaches the chart directly.
  • Repeating every key in every environment override file. Fix: put every key with its most common value in the base file (default.yaml) and let each environment file hold only its deltas — repeating an unchanged key isn't wrong, just noise.
  • Forgetting condition: when triaging "why isn't X running in staging". Fix: check whether the environment's values disable the release via condition: before assuming a plain values typo — environments can differ in which releases run, not just their settings.
Check yourself — you set certManager.enabled: false in environments/staging.yaml. What actually changes on the cluster, beyond a config value?

If a release's condition: reads that key (e.g. condition: certManager.enabled), the release itself stops being deployed to staging — the environment value doesn't just tune the release, it can remove it from the stack entirely.

Check yourself — in an environment's secrets: list, does a key set there override the same key set in values:, or the reverse?

Whichever list position comes later wins — secrets: entries merge into the same .Environment.Values tree as values: and are conceptually just a second, SOPS-decrypted values list, not a separate namespace.

You can defend this when you can explain why setting a key in environments/prod.yaml has no effect by itself until a templated values file reads .Environment.Values, why a later entry in an environment's values: list wins over an earlier one, and why switching -e can change which releases run and not just their configuration.

Go deeper: the full precedence spec, including CLI --state-values-set and defaults merging, is in the Helmfile docs — this page covers the 80/20 you need for the Do exercise.