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
From lowest to highest precedence:
- A subchart’s own
values.yaml - The parent chart’s
values.yaml -f/--valuesfiles, in the order given on the command line--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
enabledfor optional features.ingress.enabledreads 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 withtoYaml.
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=twofails 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
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.