DEV Community

Cover image for Kustomize patch env variable with valueFrom in a Helm chart
Moep90 for kadet-hub

Posted on Originally published at kadet-hub.org

Kustomize patch env variable with valueFrom in a Helm chart

Many charts take extra environment variables as a map of names to values
and render each entry as name and value. A variable that comes from a
Secret, a ConfigMap or the downward API needs valueFrom, which such a map
cannot express. This post adds those entries to the rendered Deployments
instead, merged into the container by its name, first with a Kustomize
strategic merge patch over helm template, then with
Kapitan's mutate
generator. The two cases come from open issues
against the KEDA HTTP add-on chart and the Crossplane chart.

Each patch was rendered from the chart version named in its section with
Helm 4.3.1, Kustomize 5.8.3, and Kapitan 0.36.3 with mutate 0.1.0, and the
result was installed on a kind cluster on Kubernetes 1.37.0. Both charts
come from HTTP chart repositories, and each downloaded package matched the
sha256 its repository's index.yaml lists. The guide
Patch a Helm chart compares the places such a
patch can live.

KEDA HTTP add-on chart: env with valueFrom {#keda-http-add-on-env}

kedacore/charts#761 asked
in June 2025 for extra environment variables on the HTTP add-on's
interceptor, scaler and operator. Pull request #777, merged in October
2025, added extraEnvs to each, as a map of names to values. In July
2026 a commenter
pointed out
that a map cannot set variables from ConfigMaps, Secrets or the downward
API, gave HOST_IP from status.hostIP as the example, and proposed an
env list. Nobody has replied. Chart 0.16.0 renders the map in
interceptor/deployment.yaml:

        {{- range $key, $value := .Values.interceptor.extraEnvs }}
        - name: {{ $key }}
          value: {{ $value | quote }}
Enter fullscreen mode Exit fullscreen mode

Given the commenter's entry as a map, the chart quotes the map as a
string, and the container gets that text as the value:

keda-add-ons-http: the value as an env entry: [{'name': 'HOST_IP', 'value': 'map[valueFrom:map[fieldRef:map[fieldPath:status.hostIP]]]'}]
Enter fullscreen mode Exit fullscreen mode

KEDA's core chart 2.21.0 has an env list for the operator, the metrics
adapter and the webhooks. The add-on chart has none.

Kustomize over helm template

helm template http-add-on keda-add-ons-http --repo https://kedacore.github.io/charts \
  --version 0.16.0 --namespace keda > helm.yaml
kustomize build .
Enter fullscreen mode Exit fullscreen mode

with this kustomization.yaml next to helm.yaml:

resources:
  - helm.yaml
patches:
  - patch: |-
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: keda-add-ons-http-interceptor
        namespace: keda
      spec:
        template:
          spec:
            containers:
              - name: keda-add-ons-http-interceptor
                env:
                  - name: HOST_IP
                    valueFrom:
                      fieldRef:
                        fieldPath: status.hostIP
Enter fullscreen mode Exit fullscreen mode

Kustomize knows the schema of a Deployment, so the strategic merge finds the
container by name and the entry by its name within env, and keeps the
chart's other entries. It puts the new entry first in the list. Apart from
that entry, and from fields whose value is null, which Kustomize drops, the
output equalled the plain render.

Kapitan's mutate

The class pins the chart by version:

parameters:
  kapitan:
    dependencies:
      - type: helm
        source: https://kedacore.github.io/charts
        chart_name: keda-add-ons-http
        version: 0.16.0
        output_path: charts/keda-add-ons-http
    compile:
      - input_type: helm
        input_paths: [charts/keda-add-ons-http]
        output_path: keda-add-ons-http
        helm_params:
          name: http-add-on
          namespace: keda
Enter fullscreen mode Exit fullscreen mode

merge alone would replace the containers list with the one entry of the
patch. merge_lists merges containers and env by name instead, at any
depth:

parameters:
  mutations:
    interceptor-host-ip:
      match: {apiVersion: apps/v1, kind: Deployment, name: keda-add-ons-http-interceptor}
      merge_lists: {containers: name, env: name}
      merge:
        spec:
          template:
            spec:
              containers:
                - name: keda-add-ons-http-interceptor
                  env:
                    - name: HOST_IP
                      valueFrom:
                        fieldRef: {fieldPath: status.hostIP}
Enter fullscreen mode Exit fullscreen mode

The target adds mutate as a second compile entry on
the chart's output_path, as the guide shows. mutate appends the entry at
the end of env, and nothing else in the render changed.

On kind

The add-on needs KEDA itself, so the test installed KEDA's chart 2.21.0
first. All six Deployments became Available, the interceptor Pod became
Ready, and the live Pod carries the entry, with the apiVersion the API
server fills in:

{"name":"HOST_IP","valueFrom":{"fieldRef":{"apiVersion":"v1","fieldPath":"status.hostIP"}}}
Enter fullscreen mode Exit fullscreen mode

The Kustomize output passed a server-side dry run against the same
cluster.

Crossplane Helm chart: env from a Secret {#crossplane-env-secret}

crossplane/crossplane#7792,
opened in September 2026, needs HTTP_PROXY for every Crossplane pod from a
corporate egress proxy whose URL carries a password. extraEnvVarsCrossplane
takes literals only, so the password ends up in plain text in the values
repository, which the reporter's scanner flags. Another user offered to
implement valueFrom support. No pull request or maintainer reply exists
yet. Chart 2.4.2 renders the map in
deployment.yaml:

        {{- range $key, $value := .Values.extraEnvVarsCrossplane }}
          - name: {{ $key | replace "." "_" }}
            value: {{ $value | quote }}
Enter fullscreen mode Exit fullscreen mode

With the issue's entry as a map, the chart renders it as a string like
the KEDA chart does:

crossplane: the value as an env entry: [{'name': 'HTTP_PROXY', 'value': 'map[valueFrom:map[secretKeyRef:map[key:url name:http-proxy]]]'}]
Enter fullscreen mode Exit fullscreen mode

Kustomize over helm template

helm template crossplane crossplane --repo https://charts.crossplane.io/stable \
  --version 2.4.2 --namespace crossplane-system > helm.yaml
kustomize build .
Enter fullscreen mode Exit fullscreen mode

with this kustomization.yaml:

resources:
  - helm.yaml
patches:
  - patch: |-
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: crossplane
        namespace: crossplane-system
      spec:
        template:
          spec:
            containers:
              - name: crossplane
                env:
                  - name: HTTP_PROXY
                    valueFrom:
                      secretKeyRef:
                        name: http-proxy
                        key: url
Enter fullscreen mode Exit fullscreen mode

The Deployments crossplane and crossplane-rbac-manager both name their
container crossplane, so the patch selects the Deployment by name. As
with the KEDA chart, the entry went first and nothing else changed.

Kapitan's mutate

The class pins chart 2.4.2 like the KEDA one, and the mutation has the same
shape:

parameters:
  mutations:
    crossplane-http-proxy:
      match: {apiVersion: apps/v1, kind: Deployment, name: crossplane}
      merge_lists: {containers: name, env: name}
      merge:
        spec:
          template:
            spec:
              containers:
                - name: crossplane
                  env:
                    - name: HTTP_PROXY
                      valueFrom:
                        secretKeyRef: {name: http-proxy, key: url}
Enter fullscreen mode Exit fullscreen mode

On kind

The Secret http-proxy did not exist when the mutated output was applied,
so the kubelet could not start the container:

secret "http-proxy" not found
Enter fullscreen mode Exit fullscreen mode

Once the Secret existed, with an http:// URL as url, both Deployments
became Available, and the live Pod carries the reference:

{"name":"HTTP_PROXY","valueFrom":{"secretKeyRef":{"key":"url","name":"http-proxy"}}}
Enter fullscreen mode Exit fullscreen mode

The proxy in the Secret does not exist, and Crossplane still reached the
API server: Go programs use HTTP_PROXY for http:// URLs only, and the
API server is reached over HTTPS. The Kustomize output passed a
server-side dry run.

When the container name does not match

Both tools merge by the name they are given and add a new list entry when
no container has it. With the KEDA patch changed to the name
interceptor, the mutate compile and the Kustomize build both passed, and
each Deployment then had a second container with nothing but a name and
env. The API server refused both, mutate's output, where the extra
container comes second, and Kustomize's, where it comes first:

The Deployment "keda-add-ons-http-interceptor" is invalid: spec.template.spec.containers[1].image: Required value
The Deployment "keda-add-ons-http-interceptor" is invalid: spec.template.spec.containers[0].image: Required value
Enter fullscreen mode Exit fullscreen mode

mutate's expects counts the matched documents, here the one Deployment,
so it does not catch a container name that a chart release changes. A
server-side dry run in CI does.

Originally published at kadet-hub.org.

Top comments (0)