The problem
A change "did nothing": someone set a value, Helm said Upgrade complete, and the app still behaves the old way. Nine times out of ten the value lost to another layer, went to a key the chart never reads, or was dropped by the next upgrade. To answer "where does this number come from?", you need the merge rules.
What you need to know already: 25.19 (chart, values, release, revision), 15.29 (ConfigMaps), 17.1 (requests and limits).
Words for this lesson
- layer - one source of values (the chart's defaults, a file, a
--set). - override - a later layer replacing a value from an earlier one.
- merged / computed values - the final values after all layers, what the templates actually see.
- user-supplied values - only what you passed (
-f,--set), without the chart's defaults. - subchart - a chart included inside another chart (lesson 25.29).
-f FILE (or --values FILE) passes a YAML file of values; --set KEY=VALUE passes one value on the command line, with dots for nesting (--set image.tag=1.4.1 = image: {tag: 1.4.1}).
The merge, in order
For one install or upgrade, Helm builds the values from these layers, each one overriding the one before:
1. the chart's values.yaml (defaults)
2. for a subchart: the parent's values under the subchart's name (and global:, 25.29)
3. -f / --values files, left to right (the last file wins)
4. --set-json } by KIND, in this order,
5. --set } whatever order you typed them;
6. --set-string } left to right within a kind
7. --set-file
8. --set-literal
So helm upgrade app ./chart -f base.yaml -f prod.yaml --set replicaCount=5 always ends with 5 replicas, and -f prod.yaml -f base.yaml lets base win over prod - the order of -f is the most common real mistake. --set beats any file, which is why pipelines that pass --set for "just the image tag" and then someone adds image.tag to a values file get a surprise: the file loses.
Maps merge, lists do not
In YAML a map is a set of key: value pairs; a list is a sequence of - item entries (or [a, b]). The two files below side by side:
# values.yaml (chart) # prod.yaml
resources: resources:
requests: requests:
cpu: 50m memory: 384Mi
memory: 256Mi
limits:
memory: 512Mi
Result: requests: {cpu: 50m, memory: 384Mi}, limits: {memory: 512Mi}. Maps are merged key by key, recursively. Lists are not: if the chart default is tolerations: [a, b] (tolerations, Ch 17) and your file says tolerations: [c], you get [c]. There is no way to append to a list from values - that is why good charts use maps keyed by name (extraEnv: {FOO: bar}) and render the list in the template.
null deletes. null is YAML for "no value". To remove a default entirely, set it to null:
resources:
limits: null # no limits block at all in the output
or --set resources.limits=null. An empty map limits: {} does not remove the chart's keys - it merges nothing into them.
--set, typed
--set has its own tiny language, and it guesses the type of each value (integer, true/false boolean, or string = text). int64 below is a whole-number type:
--set replicaCount=3 int64 3
--set enabled=true bool true ("True", "TRUE" too)
--set tag=1.10 string "1.10" (floats are NOT converted)
--set tag=0123 string "0123" (leading zero: stays a string)
--set x=null deletes x
--set-string replicaCount=3 string "3" (a Deployment will reject it)
--set list={a,b,c} a list
--set env[0].name=FOO index into a list (creates it)
--set 'nodeSelector.kubernetes\.io/os=linux' a dot in a key must be escaped
--set 'args={--port,8080}' braces: a list; a comma inside a VALUE needs \,
--set-json 'resources={"limits":{"memory":"1Gi"}}'
--set-file config=./app.properties the file's content as a string
YAML files have their own traps, and they bite harder because nobody reads the type: tag: 1.10 in a values file is the float 1.1 (the image app:1.1); country: NO and enabled: on are YAML 1.1 booleans in some parsers; mode: 0755 may become 493. Quote anything that is a version, an id, or a code: tag: "1.10".
Numbers from values files are decoded through JSON, so every number in values.yaml is a float64 (a decimal-number type, like 1.5) - and Go prints big floats in exponent form: maxBytes: 1500000 renders as 1.5e+06, 1000000 as 1e+06. Small ones look fine (3 prints 3), which is why this bites months later when someone raises a limit. {{ .Values.maxBytes | int }} (pipe it through int to turn it back into a whole number) (or a quoted string in the values) fixes it. --set integers are int64 and print normally - the same key can render differently depending on where it was set.
A wrong key is not an error
Helm has no idea what keys a chart reads. This is accepted silently:
helm upgrade payments-api ./chart --set replica=5 # the chart reads replicaCount
helm upgrade payments-api ./chart -f timeout.yaml # timeout.yaml nests under payments-api:
The release gets a new revision, STATUS: deployed, Upgrade complete - and nothing changes. Two defences:
- Compare
helm get values(what was supplied) with what the templates read. - Ship a
values.schema.json(a JSON Schema, 25.19: it lists which keys exist and what type each must be). Helm validates the merged values on install, upgrade, lint and template:
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"replicaCount": { "type": "integer", "minimum": 1 },
"image": { "type": "object" },
"config": {
"type": "object",
"additionalProperties": false,
"properties": {
"profile": { "type": "string" },
"gatewayUrl": { "type": "string", "pattern": "^https?://" },
"gatewayTimeout": { "type": "string", "pattern": "^[0-9]+(ms|s|m)$" }
}
}
}
}
Reading it: the values must be an object with replicaCount (an integer of at least 1), image and config; pattern is a regular expression the string must match. With "additionalProperties": false a stray key fails the upgrade before anything is applied:
Error: UPGRADE FAILED: values don't meet the specifications of the schema(s) in the following chart(s):
payments-api:
- at '': additional properties 'replica' not allowed
Upgrade and the old values
What happens to the values of the previous revision on helm upgrade?
no values flag at all the previous revision's supplied values are REUSED
any -f/--set given ONLY this command's values (+ the new chart's defaults)
--reset-values only this command's values, even if there are none
--reuse-values previous supplied values + this command's, merged -
and the new chart's defaults are IGNORED (the old
computed values stand in for them)
--reset-then-reuse-values new chart defaults + previous supplied + this command's
The second line is the one that loses production settings: the pipeline always passed -f prod.yaml, someone runs helm upgrade payments-api ./chart --set image.tag=1.4.1 by hand and prod drops to the chart defaults (2 replicas, no profile). The --reuse-values trap is subtler: a new chart version adds a default (say a readiness probe path) and your release never gets it. Since Helm 3.14, --reset-then-reuse-values is the flag you actually want for a manual change - and the real answer is that manual upgrades should not exist: the pipeline passes every file, every time.
Reading what a release got
helm get values payments-api -n pay-prod # USER-SUPPLIED VALUES only
helm get values payments-api -n pay-prod --all # COMPUTED VALUES: the full merge
helm get values payments-api -n pay-prod --revision 4
helm template payments-api ./chart -f prod.yaml --show-only templates/configmap.yaml
--all adds the chart defaults (the full merge); --revision 4 looks at an older revision. --show-only (-s) renders one template, which is the fastest way to answer "what would this value do" without a cluster.
What you can now do
- Predict which layer wins for any value, including the
-forder and--setkinds. - Remove a default with
null, and avoid the list, float and type traps. - Say what
helm upgradedoes with the previous values, and pick the right--reset/--reuseflag.