Values and Their Precedence

The exact order in which values sources override each other, why lists replace rather than merge, and how to design a values interface somebody else can use.

beginner 18 min lesson hands-on task included

Most “why is my chart doing that” questions are values questions, and almost all of them are answered by one rule and one command: later sources win, and helm template --debug shows you the merged result rather than making you infer it.


Topic 1: The Precedence Order

VALUES MERGE IN ORDER — LATER WINS, KEY BY KEY subchart values.yaml the dependency's own defaults chart values.yaml YOUR defaults — and your documentation -f base.yaml first file on the command line -f prod.yaml later -f files override earlier ones --set image.tag=1.6.0 highest precedence, and the hardest to audit ← WINS MAPS MERGE. LISTS DO NOT. A list in an override REPLACES the default list entirely — every time. SEE THE RESULT, DO NOT INFER IT helm template … --debug · helm get values RELEASE -a
Read it bottom-up when debugging: --set beats every file, later -f beats earlier -f, your chart's defaults beat a subchart's. The two panels at the bottom are the parts that surprise people.

From lowest to highest precedence:

  1. A subchart’s own values.yaml
  2. The parent chart’s values.yaml
  3. -f / --values files, in the order given on the command line
  4. --set, --set-string, --set-json, --set-file
helm upgrade --install checkout ./chart \
  -f values/base.yaml \
  -f values/prod.yaml \
  --set image.tag=1.6.0

prod.yaml beats base.yaml; --set beats both. Order on the command line is the whole rule — reversing the two -f flags silently changes the deployment.

Merging is per key, recursively, for maps. Setting resources.limits.memory does not delete resources.requests.

Lists are replaced, never merged. This one costs people real time:

# chart values.yaml
tolerations:
  - key: spot
    operator: Exists
  - key: gpu
    operator: Exists

# prod.yaml
tolerations:
  - key: dedicated
    operator: Exists

The result has one toleration, not three. There is no list-merge semantic in Helm, and no flag to enable one. If callers must add to a list rather than replace it, the chart has to model that explicitly — for example extraTolerations that the template concatenates:

tolerations:
  {{- toYaml (concat .Values.tolerations .Values.extraTolerations) | nindent 8 }}

Topic 2: The —set Family, and Its Sharp Edges

--set replicaCount=3
--set image.tag=1.6.0
--set 'ingress.hosts[0].host=api.example.com'
--set 'nodeSelector.kubernetes\.io/os=linux'      # escape dots inside a key
--set-string version=1.60                         # keep it a string, not a float
--set-json 'tolerations=[{"key":"spot","operator":"Exists"}]'
--set-file caCert=./ca.crt                        # file contents as the value

Three traps, in order of how often they bite:

Type coercion. --set version=1.60 becomes the number 1.6, and your image tag is now wrong. --set-string exists for this. Anything that looks numeric — versions, phone numbers, zero-prefixed IDs — needs it.

Commas are separators. --set cmd="a,b" sets two values. Escape them: --set cmd="a\,b".

--set is invisible to review. A values file is in Git, reviewable and diffable. A --set in a pipeline is a string in a YAML file somewhere else, and it silently outranks everything the reviewers looked at. Keep --set for the one value that genuinely varies per run — usually the image tag — and put everything else in a file.

helm get values RELEASE shows what was supplied for a live release, which is how you discover the --set somebody added during an incident eight months ago and never removed.


Topic 3: Seeing the Merged Result

Never infer the merge. Print it:

helm template checkout ./chart -f values/prod.yaml --set image.tag=1.6.0 --debug

--debug prints the computed values above the rendered manifests — the complete merged tree, which is the single most useful debugging output in Helm.

# For a live release
helm get values checkout -n prod        # only what was supplied
helm get values checkout -n prod -a     # merged with chart defaults — the real config

# Compare what is running against what the chart would render now
diff <(helm get values checkout -n prod -a) \
     <(helm template checkout ./chart -f values/prod.yaml --debug 2>&1 | sed -n '/COMPUTED VALUES/,/^---/p')

Topic 4: Designing a Values Interface

Your values.yaml is an API. It gets copied into other people’s repositories, referenced in their pipelines, and it is expensive to change once anyone depends on it.

Structure by concern, not by Kubernetes object. Someone configuring your chart is thinking “I want two replicas and an ingress”, not “I want to change .spec.template.spec.containers[0].resources”:

replicaCount: 2

image:
  repository: ghcr.io/acme/checkout
  tag: ""                 # defaults to .Chart.AppVersion
  pullPolicy: IfNotPresent

ingress:
  enabled: false
  className: nginx
  annotations: {}
  hosts: []
  tls: []

resources: {}             # deliberately empty; document the recommendation

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

podAnnotations: {}
nodeSelector: {}
tolerations: []
affinity: {}

Four rules that make it usable:

  • Booleans named enabled for optional features. ingress.enabled reads clearly in both the values file and the template.
  • Escape hatches. podAnnotations, extraEnv, extraVolumes, podSecurityContext — every chart eventually meets a requirement the author did not predict, and an escape hatch prevents a fork.
  • Empty defaults for anything environment-specific. resources: {} with a documented recommendation beats a default that is wrong everywhere.
  • Do not invent a second naming convention. If a value maps onto a Kubernetes field, use the Kubernetes name — nodeSelector, tolerations, affinity, topologySpreadConstraints — and pass it straight through with toYaml.

Generate the documentation with helm-docs, driven by # -- comments in values.yaml, so the README cannot drift from the values file.


Topic 5: values.schema.json

A schema turns “the chart broke oddly” into “you passed a string where a number was required”:

{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["image"],
  "properties": {
    "replicaCount": { "type": "integer", "minimum": 0, "maximum": 100 },
    "image": {
      "type": "object",
      "required": ["repository"],
      "properties": {
        "repository": { "type": "string", "minLength": 1 },
        "tag": { "type": "string" },
        "pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
      }
    },
    "ingress": {
      "type": "object",
      "properties": {
        "enabled": { "type": "boolean" },
        "hosts": { "type": "array", "items": { "type": "object" } }
      }
    }
  }
}

Validation runs on install, upgrade, lint and template, before rendering. What it buys:

  • A typo (replicaCounts) can be rejected outright with "additionalProperties": false — powerful, and use it deliberately, since it also blocks the escape hatches somebody may legitimately need.
  • Types are enforced, so --set replicaCount=two fails immediately instead of rendering an invalid Deployment.
  • The schema is machine-readable documentation, and editors will autocomplete against it.

Its limit: the schema validates the values, not the rendered output. A valid value can still produce an invalid manifest — that is what --dry-run=server is for.


Topic 6: Multiple Environments Without Duplication

ONE CHART, LAYERED VALUES, AND SECRETS THAT NEVER ENTER EITHER chart values.yaml safe defaults replicas: 1 resources: small ingress: disabled values-dev.yaml only the differences replicas: 1debug: true values-stg.yaml only the differences replicas: 2ingress: on values-prod.yaml only the differences replicas: 6pdb + hpa on NEVER IN A VALUES FILE passwords · tokens · TLS keys · connection strings They end up in Git, in the release Secret, and in every `helm get values`. WHERE THEY BELONG INSTEAD External Secrets Operator · Secrets Store CSI · SOPS The chart references a Secret by name; the value arrives from the store. THE RULE THAT KEEPS ENVIRONMENTS COMPARABLE An environment file contains only what DIFFERS. If prod.yaml is 300 lines, the chart's defaults are wrong — and no two environments can be diffed usefully any more.
An environment file should contain only the differences. When prod.yaml grows past a screen, the chart's defaults are usually the thing that is wrong.
chart/values.yaml        safe defaults: 1 replica, small resources, no ingress
values/base.yaml         everything true for every environment
values/dev.yaml          only what differs in dev
values/staging.yaml
values/prod.yaml         only what differs in prod
helm upgrade --install checkout ./chart -n prod \
  -f values/base.yaml -f values/prod.yaml \
  --set image.tag="${GIT_SHA}"

The diff test: diff values/staging.yaml values/prod.yaml should be short and should read as a list of deliberate decisions. When it is 200 lines, environments have diverged in ways nobody chose, and no reviewer can tell whether a staging test proves anything about production.

Secrets do not belong in any of these files. They end up in Git, in the release Secret, and in every helm get values output. The chart should reference a Secret by name; the value should arrive from External Secrets Operator, the Secrets Store CSI driver, or SOPS-encrypted files decrypted at deploy time. This gets its own treatment in the secrets lesson.

Try it yourself: set replicaCount in all four places and run helm template --debug. Then swap the order of the two -f flags and run it again. The value changes, nothing else does, and no error is produced — which is exactly why the ordering rule is worth knowing before a pipeline depends on it.

Common mistake: overriding a list — tolerations, env, hosts, command — and expecting the entries to be added to the chart’s defaults. You get exactly what you wrote and nothing else. The symptom is a pod that schedules nowhere, or an environment variable that vanished, and the values file looks correct in review because the missing entries are somewhere else entirely.