Values & overrides
Configure a chart per environment.
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.
helm show values ./web-chart
# Default values for web-chart.replicaCount: 1image:repository: registry.example.com/webtag: "1.8.0"pullPolicy: IfNotPresentpodAnnotations: {}resources:requests:cpu: 100mmemory: 128Miingress:enabled: falsehosts: []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.
# 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
NAME: webLAST DEPLOYED: Fri Jul 17 09:14:22 2026NAMESPACE: defaultSTATUS: deployedREVISION: 1TEST 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: shared by every environment, lives in your deploy repoimage:repository: registry.example.com/webenv:- name: LOG_FORMATvalue: json- name: METRICS_PORTvalue: "9090"resources:requests:cpu: 250mmemory: 256Mi
# prod.yaml: only the deltas from base.yaml, everything else is inheritedreplicaCount: 3resources:requests:cpu: 500mmemory: 512Miingress:enabled: truehosts:- host: web.example.compaths:- 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.
helm template web ./web-chart -f base.yaml -f prod.yaml
---# Source: web-chart/templates/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: webspec:replicas: 3template:spec:containers:- name: webimage: "registry.example.com/web:1.8.0"imagePullPolicy: IfNotPresentresources:requests:cpu: 500mmemory: 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.
helm get values web --all
COMPUTED VALUES:env:- name: LOG_FORMATvalue: json- name: METRICS_PORTvalue: "9090"image:pullPolicy: IfNotPresentrepository: registry.example.com/webtag: 1.8.3ingress:enabled: truehosts:- host: web.example.compaths:- path: /pathType: PrefixpodAnnotations:build: "0042"replicaCount: 3resources:requests:cpu: 500mmemory: 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.
helm template web ./web-chart -f base.yaml \--set 'env[0].name=FEATURE_X' --set 'env[0].value=on' \| grep -A4 'env:'
env:- name: FEATURE_Xvalue: "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.
helm upgrade web ./web-chart --set db.password=Sup3rS3cret &ps aux | grep '[h]elm upgrade'
deploy 48213 12.0 1.1 1290540 91264 pts/1 Sl 09:20 0:00 helm upgrade web ./web-chart --set db.password=Sup3rS3cret
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.