CoursesHelmValues & overrides

Values & overrides

Configure a chart per environment.

Intermediate12 min · lesson 4 of 12

A Helm chart (a packaged, reusable template for deploying an app to Kubernetes, the system that runs your containers) shows up like a new appliance with every dial already set at the factory. The author picked numbers that work: one replica (one running copy of the app), a small memory limit, encryption switched off, a known-good image tag. All of it sits in one file inside the chart, called values.yaml. You almost never rebuild the appliance. You walk up and nudge two or three dials for the room you are standing in.

One replica in your dev cluster, three in production. A NodePort in dev (a NodePort is a simple way to expose a service on a port of every node), a real hostname with TLS (Transport Layer Security, the lock icon on HTTPS) in prod. A bigger memory limit where real traffic lands. Values and overrides are how one chart becomes many environment-specific releases (a release is one installed instance of a chart) without ever copying or forking the templates. The author defines the shape and the defaults. You supply thin layers on top that say only what differs. The whole skill is knowing the exact order those layers stack in.

Read the factory settings first

Before you change a dial, look at where it starts. The chart's own values.yaml is the floor of everything: it holds a default for every knob the templates read. For a chart you wrote, you open the file. For a chart you pulled from a public repository, helm show values prints the same content so you can see every setting before you install anything. Read it like a spec sheet. It tells you which keys exist, what they are named, and what they default to.

terminal
helm show values ./web-chart
output
# Default values for web-chart.
replicaCount: 1
image:
repository: registry.example.com/web
tag: "1.8.0"
pullPolicy: IfNotPresent
podAnnotations: {}
resources:
requests:
cpu: 100m
memory: 128Mi
ingress:
enabled: false
hosts: []
env: []

Helm builds a single merged values object before any template runs, and it does a deep merge of maps. That means you only restate the keys you are changing, not the whole tree. Keep your overrides thin. Every default you copy into your own files is a default that will silently go stale the day the chart author changes it in an upgrade, because your copy keeps winning. Say what differs and let the rest flow through.

How the layers stack

The order is strict and it is last-wins. The chart's values.yaml is the bottom. On top of it, Helm layers each -f (or --values) file you pass, in the order you list them, so a file named later beats a file named earlier. Highest of all are the inline flags: --set and its typed cousins. Think of it like stacking transparent sheets on an overhead projector. Each new sheet only draws the lines it wants to change, and where two sheets draw over the same spot, the top sheet is what you see.

terminal
# Precedence, low to high:
# chart values.yaml < base.yaml < prod.yaml < --set (and --set-string)
helm install web ./web-chart \
-f base.yaml \
-f prod.yaml \
--set image.tag=1.8.3 \
--set-string podAnnotations.build=0042 # keep the leading zero as a string
output
NAME: web
LAST DEPLOYED: Fri Jul 17 09:14:22 2026
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None

The typed cousins of --set exist because YAML (the indentation-based text format Kubernetes configs are written in) guesses at types and sometimes guesses wrong. --set-string never coerces: podAnnotations.build=0042 stays the text "0042" instead of collapsing to the number 42. --set-json takes a raw JSON value (JSON, the format for structured data), so you can assign a whole array or object, or an explicit null. --set-file reads the value straight from a file on disk, which is handy for a multi-line certificate you would rather not paste onto a command line. Reach for a file (-f) when you are changing more than a couple of keys, and for --set when it is one or two.

One chart, every environment

The pattern that lasts is a base file holding the settings every environment shares, plus one thin file per environment holding only the deltas. Commit both next to your deploy config, not inside the chart, so the chart stays generic and anyone can reuse it. Each environment's release is then the same chart with a different top sheet. Dev gets one replica and a NodePort. Prod gets three replicas, an ingress hostname (ingress is the rule set that routes outside traffic to a service running inside the cluster), and heavier resource requests. The command shape is identical across environments, you swap one filename, and that sameness is exactly what makes it safe to run from CI (continuous integration, the automated pipeline that builds and ships your code) or GitOps (managing your cluster's config through git commits).

base.yaml
# base.yaml: shared by every environment, lives in your deploy repo
image:
repository: registry.example.com/web
env:
- name: LOG_FORMAT
value: json
- name: METRICS_PORT
value: "9090"
resources:
requests:
cpu: 250m
memory: 256Mi
prod.yaml
# prod.yaml: only the deltas from base.yaml, everything else is inherited
replicaCount: 3
resources:
requests:
cpu: 500m
memory: 512Mi
ingress:
enabled: true
hosts:
- host: web.example.com
paths:
- path: /
pathType: Prefix

Because the merge is additive on maps, prod.yaml never repeats the dozens of keys it shares with base.yaml. It states its differences and inherits the rest. Notice that resources is a map, so if prod.yaml set only requests.cpu, the requests.memory from base.yaml would still come through. That key-by-key blending is the behavior you want, and it is also the behavior that has one sharp exception, which the next section is about.

See what actually got merged

Never guess which layer won. Render it. helm template computes the merged values and prints the resulting manifests (the YAML files that tell Kubernetes what to create) on your machine, with no contact to any cluster, so you can eyeball the exact YAML that would ship and diff dev against prod before anything moves.

terminal
helm template web ./web-chart -f base.yaml -f prod.yaml
output
---
# Source: web-chart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
template:
spec:
containers:
- name: web
image: "registry.example.com/web:1.8.0"
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 500m
memory: 512Mi

On an actual install or upgrade, --dry-run --debug does the same round-trip through the API server (the control-plane component that accepts every change to the cluster) and echoes both the USER-SUPPLIED VALUES and the full COMPUTED VALUES it used. After a release is live, helm get values shows what you supplied, and adding --all expands it to the fully merged object, every untouched chart default included. That last one is your source of truth for what is really running.

terminal
helm get values web --all
output
COMPUTED VALUES:
env:
- name: LOG_FORMAT
value: json
- name: METRICS_PORT
value: "9090"
image:
pullPolicy: IfNotPresent
repository: registry.example.com/web
tag: 1.8.3
ingress:
enabled: true
hosts:
- host: web.example.com
paths:
- path: /
pathType: Prefix
podAnnotations:
build: "0042"
replicaCount: 3
resources:
requests:
cpu: 500m
memory: 512Mi

Maps blend, lists replace

Here is the trap that bites everyone once. The deep merge only applies to maps (key and value blocks). Lists do not merge, they replace. When two layers set the same array, env vars, ingress hosts, container args, volumes, the higher layer overwrites the whole list. It does not append. Watch what happens when you add one env var the obvious way on top of a base.yaml that already defines two.

terminal
helm template web ./web-chart -f base.yaml \
--set 'env[0].name=FEATURE_X' --set 'env[0].value=on' \
| grep -A4 'env:'
output
env:
- name: FEATURE_X
value: "on"

LOG_FORMAT and METRICS_PORT from base.yaml are gone. Your one-line addition became the entire list. To add to a list you have to restate every element in the winning layer, or design the chart so environments toggle separate keys instead of all editing one shared array. The mirror-image trap lives on the map side. To remove an inherited key for good, set it to null (in a -f file or with --set key=null); leaving the key out does not delete anything, it only lets the lower layer's value survive. If you meant to switch something off and it is still on, that is usually why.

What a defender checks

Override precedence is a security surface, because whatever wins is what runs. A rogue or careless --set slipped into a CI pipeline can flip image.tag to an unreviewed build, disable ingress TLS, or drop a securityContext (the block that limits what a container is allowed to do), and none of it touches the chart or your reviewed files. helm get values web --all is where you catch it. In an incident, read the computed values against what you expect. If a setting you never chose is present, or one you set is missing, a layer you forgot about is winning.

There is also a leak to watch. Anything you pass with --set ends up on the command line, and on a shared host command lines are not private.

terminal
helm upgrade web ./web-chart --set db.password=Sup3rS3cret &
ps aux | grep '[h]elm upgrade'
output
deploy 48213 12.0 1.1 1290540 91264 pts/1 Sl 09:20 0:00 helm upgrade web ./web-chart --set db.password=Sup3rS3cret
Inline --set leaks secrets
Anything you pass with --set lands in the process table (any user who can run ps, the command that lists running processes, sees it), in your shell history file, and in CI job logs. Helm also stores the release's values in a Kubernetes Secret (an object meant to hold sensitive data) named sh.helm.release.v1.<name>.v<n> in the release namespace, so anyone allowed to get secrets there can read them. Keep passwords and tokens out of --set. Use a values file with tight permissions, --set-file to read from disk, or a real secrets manager (Sealed Secrets, SOPS, External Secrets).
How Helm builds one merged values object
1chart values.yaml
the floor: author's defaults
2-f base.yaml
settings shared across environments
3-f prod.yaml
only this environment's deltas
4--set / --set-string
highest layer, last wins
5merged values
one deep-merged object
6templates render
manifests sent to the cluster
Later layers win. Maps deep-merge; lists replace wholesale.
Quick check
01You run: helm template web ./web-chart -f base.yaml --set 'env[0].name=FEATURE_X' --set 'env[0].value=on' | grep -A4 'env:'. base.yaml defines LOG_FORMAT and METRICS_PORT, yet the printed env block holds FEATURE_X and nothing else. What happened?
Incorrect — Every -f file you pass is read and merged. The only thing helm template skips is the trip to the cluster, not your files.
Incorrect — Count the printed lines: three follow env:, so the window still had room. The two variables are genuinely missing.
Correct — Arrays get swapped out whole. If you want the original two back, name them again in the layer that wins.
Incorrect — There is no element-by-element blending for arrays. Index matching would have kept slots one and two alive, and they are gone.
02The computed values for release web show podAnnotations with build: "0042". Suppose that install had passed --set podAnnotations.build=0042 instead of --set-string. What would helm get values web --all print then?
Correct — The leading zero is the giveaway. Anything that looks numeric is fair game for coercion unless you force the string form.
Incorrect — The two flags share the same top layer, so neither outranks the other. What separates them is how they treat the type.
Incorrect — Nothing quotes it on your behalf. Those quotes in the output exist because the flag you picked refused to change the type.
Incorrect — podAnnotations: {} is an empty map, and either flag can add a key to it. A default is a starting point, not a locked door.
03prod.yaml sets ingress.enabled: true. For staging you copy prod.yaml, delete the ingress block from the copy, then run helm upgrade web ./web-chart -f base.yaml -f prod.yaml -f staging.yaml. helm get values web --all still reports ingress.enabled: true. Why?
Incorrect — Files layer in the order you type them, so the last -f is the one that wins. Ordering is not what bit you here.
Incorrect — --all reports the merged object for the revision you just shipped, which is why it works as your source of truth.
Incorrect — Only the array itself would be swapped, and your staging file names no array at all. Sibling keys merge on their own.
Correct — Say the off switch out loud, or set the key to null to drop it. Silence in the winning layer changes nothing.

Make one habit stick. Before any production upgrade, run helm get values web --all and read it against helm template of the same chart and the same files. If a value you never set shows up, or one you set is missing, some layer you forgot about is winning, and you want to know that on your screen instead of in a page at 3 a.m.

Try this

Run helm show values ./web-chart on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: inline --set leaks secrets. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related