DevOps & CI/CD Bug Report

Helm production values silently wiped our default env vars: lists replace, maps merge

After adding one environment variable in values-prod.yaml, production pods lost six others defined in the chart's defaults, including the database host. Helm deep-merges maps but replaces lists entirely. How we spotted it with helm template diffs and restructured values so overrides merge.

Sofia Andersson Sofia Andersson @sofiaandersson · September 07, 2026 · 7 min read · 41 Views
Banner: Helm production values silently wiped default env vars because lists replace and maps merge

A small change went to production on a Tuesday: the payments team added a feature flag as an environment variable in values-prod.yaml. Argo CD synced, the new pods started, and within seconds they crashed:

Error: DATABASE_HOST is not set
    at loadConfig (/app/dist/config.js:18:11)

DATABASE_HOST had never been in the production values file. It lived in the chart's default values.yaml, along with five other variables, and had always been there.

The values files

# chart/values.yaml (defaults)
env:
  - name: DATABASE_HOST
    value: payments-db.payments.svc.cluster.local
  - name: DATABASE_NAME
    value: payments
  - name: LOG_FORMAT
    value: json
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: http://otel-collector.observability:4317
  - name: HTTP_PORT
    value: "8080"
  - name: SHUTDOWN_GRACE_SECONDS
    value: "25"
# values-prod.yaml (before the change)
replicaCount: 6
resources:
  requests: { cpu: 500m, memory: 512Mi }
# values-prod.yaml (after the change)
replicaCount: 6
resources:
  requests: { cpu: 500m, memory: 512Mi }
env:
  - name: FEATURE_INSTANT_REFUNDS
    value: "true"

Rendering both versions

helm template payments ./chart -f values-prod.yaml > after.yaml
git stash && helm template payments ./chart -f values-prod.yaml > before.yaml && git stash pop
diff <(yq '. | select(.kind=="Deployment") | .spec.template.spec.containers[0].env' before.yaml) \
     <(yq '. | select(.kind=="Deployment") | .spec.template.spec.containers[0].env' after.yaml)
< - name: DATABASE_HOST
<   value: payments-db.payments.svc.cluster.local
< - name: DATABASE_NAME
<   value: payments
< ... (four more)
> - name: FEATURE_INSTANT_REFUNDS
>   value: "true"

The rendered Deployment had exactly one environment variable.

What we ruled out

  • Argo CD or a stale cache. The rendered output from helm template locally showed the same result.
  • Template logic. The template simply ranged over .Values.env; there was no conditional.
  • Multiple values files in the wrong order. Only the chart defaults and values-prod.yaml were used.

Root cause

When Helm combines values from the chart's values.yaml and user-supplied files, it deep-merges maps key by key, but a list is a single value: a list in a later file replaces the list in an earlier one entirely. Defining env in values-prod.yaml replaced all six default variables with one.

Helm's coalescing rules

Type in both files Result
Map and map Merged recursively; keys from the later file win
Scalar and scalar Later value wins
List and list Later list replaces the earlier list completely
Any value and null Key is removed from the result

This is deliberate. A generic merge of lists has no right answer (append? merge by index? by some key?), so Helm does not guess. Kubernetes manifests are full of lists, such as env, volumes, tolerations, ports and args, so chart authors who expose them directly as lists hand this trap to every consumer.

Why reviews missed it

The pull request diff showed three added lines in a values file and looked harmless. Nobody reviewed the rendered manifests, which would have shown six variables disappearing. Values-file diffs describe intent; rendered-manifest diffs describe effect.

Who is affected

Any chart that exposes a Kubernetes list directly as a value and is consumed with per-environment override files: env, envFrom, volumes, volumeMounts, tolerations, imagePullSecrets, args, hostAliases and ingress hosts are the usual suspects. Public charts often have the same design, so the issue is not limited to in-house charts. The same rule applies to --set with list indexes and to Argo CD helm.values blocks, which are merged by Helm in the same way.

Environment

Component Version / value
Helm 3.16
Deployment Argo CD Application with helm.valueFiles: [values-prod.yaml]
Chart In-house chart, env exposed as a list
Impact Production payments API crash-looping for about 4 minutes

Immediate

We reverted the commit; Argo CD synced the previous revision and pods recovered. Then we fixed the chart.

Restructure values as maps

The chart now accepts environment variables as a map, which Helm merges, and converts it to a list in the template:

# chart/values.yaml
env:
  DATABASE_HOST: payments-db.payments.svc.cluster.local
  DATABASE_NAME: payments
  LOG_FORMAT: json
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.observability:4317
  HTTP_PORT: "8080"
  SHUTDOWN_GRACE_SECONDS: "25"
# chart/templates/deployment.yaml (excerpt)
          env:
            {{- range $name, $value := .Values.env }}
            {{- if ne $value nil }}
            - name: {{ $name }}
              value: {{ $value | quote }}
            {{- end }}
            {{- end }}
            {{- with .Values.extraEnv }}
            {{- toYaml . | nindent 12 }}
            {{- end }}

Production now adds a key without touching the others:

# values-prod.yaml
env:
  FEATURE_INSTANT_REFUNDS: "true"

range over a map iterates in sorted key order, so the rendered output is stable between runs, which keeps GitOps diffs clean. Setting a key to null in an override removes it, which is now documented as the way to drop a default.

extraEnv remains a list for the cases maps cannot express, such as valueFrom secret references, and its documentation says clearly that it is not merged.

Apply the same pattern elsewhere

We audited the chart for other lists that consumers override per environment and converted the common ones:

Before (list) After (map)
env: [{name, value}] env: {NAME: value}
tolerations: [...] tolerations: {gpu: {...}, spot: {...}} rendered with range
extraVolumes: [...] extraVolumes: {cache: {...}}

Review rendered manifests, not only values

The more important process fix: every pull request that changes chart values now gets a comment with the diff of rendered manifests per environment, produced in CI:

for env in staging prod; do
  helm template payments ./chart -f values-$env.yaml > /tmp/new-$env.yaml
  git show origin/main:deploy/values-$env.yaml > /tmp/old-values.yaml
  helm template payments ./chart -f /tmp/old-values.yaml > /tmp/old-$env.yaml
  dyff between /tmp/old-$env.yaml /tmp/new-$env.yaml
done

Argo CD's own diff view shows the same thing before sync, if someone looks; making it part of the PR means someone does.

Alternatives and trade-offs

  • Repeat the full list in every environment file. Works, but every default change must be copied to every file, and someone will forget.
  • Kustomize patches on top of Helm output. Strategic merge patches merge lists like env by name. It adds a second tool to the pipeline.
  • Global plus environment lists merged in the template (concat .Values.env .Values.envOverrides). Possible, but duplicates are not deduplicated by name unless you write that logic.

How to test the fix

helm template payments ./chart -f values-prod.yaml \
  | yq 'select(.kind=="Deployment") | .spec.template.spec.containers[0].env[].name'
# DATABASE_HOST DATABASE_NAME FEATURE_INSTANT_REFUNDS HTTP_PORT LOG_FORMAT OTEL_... SHUTDOWN_...
helm unittest ./chart

We added a helm-unittest test asserting that an environment file with one env key still renders all defaults.

Confirming in production

The feature flag shipped the next day with the restructured chart. The rendered manifest diff in the pull request showed exactly one added variable, and the pods started normally.

Regression guard

  • Rendered manifest diffs on every values or chart change, posted to the pull request.
  • helm-unittest tests for merge behaviour of commonly overridden values.
  • A chart lint rule flags new top-level list values that are likely to be overridden per environment.

Security implications

Lists replaced by overrides can drop security settings too: securityContext capabilities lists, NetworkPolicy rules exposed as values, or args that enable TLS. A rendered-manifest diff makes such removals visible. Secrets should continue to come from secret references, not plain values, regardless of structure.

Prevention checklist

  • Remember that Helm merges maps and replaces lists.
  • Expose frequently overridden collections, such as environment variables, as maps in charts.
  • Review rendered manifest diffs, not only values diffs.
  • Test merge behaviour with helm-unittest.
  • Document how to remove a default (set it to null).

References

Add a label

#kubernetes #configuration #gitops #helm #values-files
Written by
Sofia Andersson @sofiaandersson

I work on how code gets from a merged pull request to production: Helm charts, Argo CD, Argo Rollouts, CI runners and the policies that decide what is allowed to run. My goal is boring deployments, the kind nobody needs to watch. Getting there means learning from the deployments that were not boring: a values file that silently dropped configuration, a key rotation that blocked every release, a preview environment that received real traffic. Each post explains the failure, the mechanism behind it, the fix and the guard rail that stops it from coming back.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.