A server-side dry run never validates your Helm values

kubectl apply --dry-run=server checks the HelmRelease CRD schema and stops there. Rendering the chart is the only cheap way to catch a mistake inside spec.values.

When a chart is deployed through a GitOps controller, the manifest you actually write is a HelmRelease: a small custom resource whose spec.values block carries the entire chart configuration as opaque YAML.

My reflex before merging one is a server-side dry run:

kubectl apply --dry-run=server -f helmrelease.yaml

It passes. It nearly always passes, and it tells you a lot less than it seems to.

What the dry run actually checks

A server-side dry run validates the resource against the CRD’s OpenAPI schema. For a HelmRelease, that means it confirms spec.chart, spec.interval and the other top-level fields are well-formed.

spec.values has no schema at all, and it can’t have one, because the controller doesn’t know the schema of a chart it hasn’t fetched yet. So any mistake inside it gets through: wrong key names, wrong nesting, values the chart will silently ignore.

That makes for about the worst kind of failure there is. The resource is admitted, the controller reports Ready, and the GitOps dashboard is green, but the thing you configured simply isn’t configured.

Render the chart instead

helm template <release> <repo>/<chart> --version <x.y.z> -n <ns> -f values.yaml

This runs the chart’s own templating, so a mistyped key visibly vanishes from the output instead of being quietly accepted. Grep the rendered result for the value you care about:

helm template ... | grep -A12 'my-config-file'

If your setting isn’t in the rendered ConfigMap, it was never going to end up in the cluster either.

Helm accepts unknown keys under a chart’s values and drops them without a warning. Rendering the chart is the only signal you get that this happened, so I don’t treat it as optional.

A worked example

I deployed Node Feature Discovery so that the node holding a USB dongle would get labelled from detection. That way a nodeSelector could follow the hardware instead of hard-coding a node name.

The manifest had two mistakes in it, and a dry run couldn’t have caught either.

The first was the config key. NFD’s worker configuration uses core.featureSources and core.labelSources. I’d reached for core.sources, which doesn’t exist in that version. Helm accepts it, drops it, and renders nothing.

The second was the device class. NFD’s usb source only labels devices whose class is listed in sources.usb.deviceClassWhitelist, which defaults to ["0e","ef","fe","ff"]. The dongle reports class 02 (CDC), like every USB-serial adapter that presents a CDC-ACM interface. 02 isn’t in the default list, so you get no label at all. Without a label, the nodeSelector matches nothing, and the pod sits in Pending for reasons that point nowhere near NFD.

I’d guessed ff (“vendor-specific”) from the chip’s marketing description. What I should have done from the start was read it off the hardware:

cat /sys/bus/usb/devices/<dev>/bDeviceClass

Rendering the chart caught both mistakes. Seeing 02 in the rendered nfd-worker.conf told me the key name was right and that the value had landed.

A layered check that costs seconds

kubectl kustomize overlays/production         # 1. YAML and kustomize errors
kubectl apply --dry-run=server -f -           # 2. CRD schema errors
helm template <release> <repo>/<chart> ...    # 3. everything inside spec.values

Only the third step tests what you actually wrote. I still run the first two, because they catch other kinds of mistakes, just not the ones in your values.

One wrinkle when you dry-run a whole overlay: a resource can’t be admitted into a namespace that’s only being created in the same batch. For the dry run, either point the resource at an existing namespace or accept that step 2 covers less than the full set.

Quoting that looks wrong and is not

Kustomize re-quotes "02" but leaves 0e, ef, fe and ff bare. That startled me, but it’s correct. 02 would parse as a number. 0e isn’t a valid YAML float, since an exponent needs digits after the e, and the others are plainly strings. Leave it alone.