🛠️ How I Dynamically Generated Helm values.yaml for Subcharts Using envsubst
Managing environment-specific values in Helm can be tricky, but it gets even harder when you’re working with pre-existing Helm charts as subcharts, and need to inject dynamic values like DNS zones, agent pools, and custom hostnames deep into their config trees.
I faced exactly this problem while deploying workloads like Jenkins and Nexus Repository Manager into a Azure Kubernetes Service (AKS) that shared multiple services and environments. Passing the right values into them became painful, especially when those values needed to be generated at deployment time.
I didn’t want to fork the charts or using fragile CLI flags that I’d have to keep adding to, so I used a combination of envsubst
and a templated values.yaml
to inject exactly what I needed, when I needed it.
Here’s how.
😩 The Problem
We were deploying a parent Helm chart that bundled services such as Jenkins and Nexus as subcharts.
The requirements were:
Each subchart had to target the same AKS agent pool
Ingress hosts had to follow a dynamic format like
jenkins-<env>-<short-id>.privatelink.mycompany.internal,
i.ejenkins-dev-1234.privatelink.mycompany.internal
Values like DNS zone and agent pool were created with the services at runtime, via CNAB outputs
Each chart expected these values in different nested paths
Example configuration:
jenkins:
controller:
nodeSelector:
agentpool: ${AKS_AGENT_POOL}
ingress:
hostName: "jenkins-${AKS_SUBDOMAIN_SUFFIX}.${AKS_DNS_ZONE}"
nexus-repository-manager:
nexus:
nodeSelector:
agentpool: ${AKS_AGENT_POOL}
ingress:
hostRepo: "nexus-${AKS_SUBDOMAIN_SUFFIX}.${AKS_DNS_ZONE}"
This meant we needed to:
Inject dynamic values into deeply nested config with different labels
Use shared logic across multiple charts
Avoid modifying third-party Helm charts
💡 The Solution: values-template.yaml
+ envsubst
Instead of fighting Helm, I created a templated values-template.yaml
file with shell-style placeholders (${VAR}
), and used Bash to populate it dynamically at runtime using envsubst
. This meant that the values.yaml file was created before ever running the helm command.
✅ Step 1: Set Environment Variables
These came from porter bundle CNAB parameters and outputs:
export AKS_SUBDOMAIN_SUFFIX=$(cat /cnab/app/outputs/aks_subdomain_suffix)
export AKS_DNS_ZONE=$(cat /cnab/app/outputs/aks_private_dns_zone)
export AKS_AGENT_POOL=$(cat /cnab/app/outputs/aks_agent_pool)
✅ Step 2: Use a values-template.yaml
jenkins:
enabled: true
controller:
nodeSelector:
agentpool: "${AKS_AGENT_POOL}"
ingress:
enabled: true
ingressClassName: "webapprouting.kubernetes.azure.com"
hostName: "jenkins-${AKS_SUBDOMAIN_SUFFIX}.${AKS_DNS_ZONE}"
nexus-repository-manager:
enabled: true
nexus:
nodeSelector:
agentpool: "${AKS_AGENT_POOL}"
ingress:
enabled: true
ingressClassName: "webapprouting.kubernetes.azure.com"
hostRepo: "nexus-${AKS_SUBDOMAIN_SUFFIX}.${AKS_DNS_ZONE}"
✅ Step 3: Generate the Final File
envsubst < "./templates/${{ bundle.parameters.workload_type }}/values-template.yaml" > "./templates/${{ bundle.parameters.workload_type }}/values.yaml"
✋ Why Not --set
, import-values
, global
, or Helmfile?
You might be wondering:
"Why not just use
--set
, Helmfile, or Helm’s built-in global values?"
Each of those paths had limitations in this use case.
❌ --set
is unreadable and fragile
Helm’s --set
CLI flag is fine for simple overrides, but breaks down with:
Deeply nested keys
Dynamic string concatenation (like
"jenkins-${env}.${zone}"
)Arrays and multi-line values
It quickly becomes hard to read, debug, or scale.
❌ import-values
doesn’t support runtime config
Helm's import-values
is limited to statically mapping values from parent charts to subcharts. It:
Requires predefined values in
Chart.yaml
Doesn’t support dynamic construction or interpolation
Would still require wrapping logic to pull in CNAB outputs
❌ global:
values don’t help with intra-file references
During research, I explored whether I could define a shared value using global:
in values.yaml
:
global:
nodeSelector:
agentpool: dev-1234
And then reference it elsewhere in the same values.yaml
, like:
jenkins:
controller:
nodeSelector:
agentpool: {{ .Values.global.nodeSelector.agentpool }}
However, this doesn’t work.
Helm does not support referencing one value from another within
values.yaml
.
The values.yaml
file is treated as a static input — not a dynamic template. Helm doesn't parse or evaluate Go templating syntax ({{ ... }}
) inside values.yaml
. That evaluation only happens in the Helm chart templates (like deployment.yaml
, configmap.yaml
, etc.).
🧩 What about YAML anchors?
I also explored using YAML anchors and aliases to reuse values, like this:
global:
nodeSelector: &commonNodeSelector
agentpool: dev-1234
jenkins:
controller:
nodeSelector: *commonNodeSelector
While this is valid YAML, it didn’t work in practice — especially when combined with values passed in via --set
.
The reason is subtle but important:
YAML anchors are evaluated when the values file is first loaded into memory.
Any overrides using--set
,-f
, or other merging strategies happen afterward — meaning the anchor has already been resolved with the old value.
To make anchors reflect updated values (e.g., those passed in via --set
), Helm would need to inject and resolve all overrides before parsing the file — which would introduce complexity and fragility into the rendering process.
Because of this order of operations:
Anchors don’t inherit values from
--set
Updates via CLI flags don’t propagate into previously anchored fields
And this breaks patterns like anchoring
nodeSelector
or shared structures for reuse
So while YAML anchors can reduce duplication in static files, they don’t work reliably in Helm — particularly when runtime overrides are involved.
✅ Why The envsubst
Pattern Worked
One important distinction was that I didn’t actually need Helm itself to handle dynamic values during installation.
I just needed to calculate the correct values during runtime — before calling
helm install
.
Using envsubst
allowed us to do exactly that: compute environment-specific values based on runtime context (like CNAB outputs), inject them into a values.yaml
, and hand that static file off to Helm.
This gave us:
✅ Pre-rendered, environment-specific values
No dynamic resolution needed by Helm; everything is prepared in advance.✅ Compatibility with third-party subcharts
No chart modifications, forks, or overrides required.✅ A simple, composable workflow
Easily integrates into CNAB/Porter’s lifecycle without special tooling.
In other words, envsubst
handled the dynamic part, so Helm didn’t have to.
🧪 Final Output Example
jenkins:
controller:
nodeSelector:
agentpool: "dev-1234"
ingress:
hostName: "jenkins-dev-1234.privatelink.mycompany.internal"
nexus-repository-manager:
nexus:
nodeSelector:
agentpool: "dev-1234"
ingress:
hostRepo: "nexus-dev-1234.privatelink.mycompany.internal"
Final Thoughts
This pattern — combining a values-template.yaml
with envsubst
— was a flexible, quick and no-fuss way to deliver dynamic values into Helm charts, without fighting Helm or breaking Porter.
It struck a good balance between:
Runtime configurability
Compatibility with CNAB and Porter
Minimal tooling and overhead
But this isn’t the end of the road.
If the complexity of values increases — for example, conditional logic, loops, or deeply nested object construction — this pattern could be extended using a full templating engine like Jinja.
That would allow for:
More expressive configuration logic
Better reuse and composition
Cleaner integration with more complex CI/CD pipelines
For now, though, envsubst
hit the sweet spot for our needs — simple, composable, and easy to drop into any bundle.
📣 Over to You
Have you had to inject dynamic config into Helm charts deployed with Porter, CNAB, or other tools?
Have you extended templating beyond envsubst
— or run into similar limitations with global:
values, --set
, or YAML anchors?
Drop a reply — I'd love to hear how you’ve tackled this, and what tooling you've leaned on to keep your deployments clean and scalable.