Skip to content

Reading a Helm Chart You Did Not Write

I set app.messaging.sqs.topic in a values file. Helm accepted it. The chart rendered without error. The deployed pod did not have the environment variable, because the chart contains no reference to SQS anywhere.

I found that by reading the template, not by debugging the application. That is the skill this post is about, because most Helm charts you deploy are charts someone else wrote.

The Problem With Values Files

A values file is an interface, and unlike most interfaces it does not tell you what it accepts.

Helm merges your values into the chart's defaults and renders templates against the result. If you supply a key nothing reads, nothing complains. There is no schema check by default, no unknown-key warning, no diff between "keys I set" and "keys that mattered".

So a values file can be simultaneously valid, well-formed, carefully considered, and completely inert.

app:
  messaging:
    provider: sqs
    sqs:
      topic: "orders-events"

That is from a real overlay I wrote. provider: sqs works, because the template does read that key. topic does not, because it does not.

Read in This Order

Four files, in this sequence. It takes about ten minutes and it answers most questions before you deploy anything.

1. values.yaml is the vocabulary, not the contract. It tells you which keys the author anticipated, and it will not tell you which of them are actually consumed.

2. templates/_helpers.tpl is where the logic lives. Named templates, naming conventions, and any value that gets computed rather than passed. This is the file people skip and it is the one that explains the surprises.

3. The templates that consume what you care about. For configuration, that is usually configmap.yaml and deployment.yaml. This is where you find out whether your key is read.

4. templates/NOTES.txt, which is often the only place the author states what they expected you to do next.

Where the Conditional Lives

Here is the entire ConfigMap template for the orders service:

data:
  RETAIL_ORDERS_MESSAGING_PROVIDER: {{ .Values.app.messaging.provider }}
  {{- if (eq "rabbitmq" .Values.app.messaging.provider) }}
  RETAIL_ORDERS_MESSAGING_RABBITMQ_ADDRESSES: {{ include "orders.rabbitmq.addresses" . }}
  {{- end }}
  RETAIL_ORDERS_PERSISTENCE_PROVIDER: {{ .Values.app.persistence.provider }}
  {{- if (eq "postgres" .Values.app.persistence.provider) }}
  RETAIL_ORDERS_PERSISTENCE_ENDPOINT: {{ include "orders.postgresql.endpoint" . }}
  RETAIL_ORDERS_PERSISTENCE_NAME: {{ .Values.app.persistence.database }}
  {{- end }}

Two conditionals, on rabbitmq and on postgres. Both providers get their connection details injected.

sqs is a valid value for provider and there is no branch for it. The provider name reaches the pod, so the application knows to use SQS, and the topic name never does.

grep -rn "sqs" templates/ values.yaml
# no matches

That one command is the whole diagnosis, and I ran it after the deployment rather than before. The fix is a manual patch that should not be necessary:

kubectl set env deployment/orders RETAIL_ORDERS_MESSAGING_SQS_TOPIC=orders-events -n orders

A values key with no consumer is invisible in both directions

Helm will not warn you that you set something unused. The chart will not warn you that a supported provider is missing its configuration branch. And the application starts, because the provider name arrived; it just cannot find the queue.

This is the same shape as ten others I collected across five projects, and it is the reason the reading order above starts with templates rather than with values.

The Pattern That Makes Charts Portable

The two helpers behind those conditionals are worth understanding, because the pattern recurs in almost every good chart:

{{- define "orders.postgresql.endpoint" -}}
{{- if .Values.postgresql.create -}}
{{ include "orders.postgresql.fullname" . }}:{{ .Values.postgresql.service.port }}
{{- else }}
{{- .Values.app.persistence.endpoint -}}
{{- end -}}
{{- end -}}

Read it as a contract with two branches:

  • create: true: the chart deploys the database, so it knows the Service name, so it builds the endpoint itself. You must not supply one.
  • create: false: the chart deploys nothing, so it cannot know where your database is. You must supply one.

That is why the same chart deploys against an in-cluster StatefulSet on a laptop and against RDS in production without a fork. The create flag is not "should I make a pod", it is "who owns the address".

It also explains a failure that reads as a bug and is not. Set create: false and forget app.persistence.endpoint, and the helper renders an empty string. The ConfigMap gets a key with no value, the pod starts, and the connection fails with something unhelpful about an empty host. The chart did exactly what the contract says.

Render Before You Install

The single most useful habit: never let helm install be the first time you see the output.

helm template orders src/orders/chart/ \
  -f src/orders/chart/values.yaml \
  -f src/orders/chart/values-06-postgresql-pvc-eks-sqs.yaml

No cluster, no release, no cleanup. It prints exactly what would be applied.

Then read the ConfigMap section of that output and compare it against the keys you set. Anything you set that is absent from the render is a key nothing consumed. That comparison would have caught the SQS gap in seconds, before a deployment, a confused pod, and a manual kubectl set env.

For a specific question, narrow it:

helm template ... | yq 'select(.kind == "ConfigMap")'

What the Order of -f Flags Means

Every scenario in this project passes two files:

-f values.yaml -f values-03-postgresql-rabbitmq-pvc-baremetal.yaml

Helm deep-merges them left to right, so the second overrides only the keys it declares. Everything unstated falls through to the base.

Two consequences worth holding.

The overlay is a patch, not a replacement, so a five-line overlay is a complete and legitimate configuration rather than an incomplete one. Reviewing it means reading it against the base, not on its own.

Deep merge does not deep-merge lists. A list in the overlay replaces the base list entirely rather than appending to it. That is the most common surprise in layered values, and it is why an overlay adding one environment variable can silently remove the other six.

Where the Names Come From

Almost every chart computes resource names rather than taking them literally, and the computation is in _helpers.tpl:

{{- define "orders.rabbitmq.fullname" -}}
{{- include "orders.fullname" . }}-rabbitmq
{{- end -}}

orders.fullname typically combines the release name and the chart name, so the Service you get depends on what you called the release.

This is why the retail UI has to reach cart-carts.cart.svc.cluster.local while the others are plain catalog.catalog. The cart chart's fullname produced cart-carts for a release named cart, and the others did not. Same convention, different chart names, inconsistent-looking output.

If you are writing anything that references a Service this chart creates, get the name from a render rather than from a pattern you inferred:

helm template ... | yq 'select(.kind == "Service") | .metadata.name'

Reading a Chart as a Fork

There is a second reason to read templates carefully, which is deciding what you are allowed to change.

I forked this repository and extended five charts without modifying a single upstream file. Every values overlay is an added file inside the chart directory, never an edit to values.yaml or a template. That constraint is worth adopting deliberately, because it decides how much pain a future upstream release causes.

  • Edit a template and every upstream change to that file is a merge conflict you resolve by hand, forever.
  • Edit values.yaml and you conflict less often but you still conflict, and you lose the ability to see what upstream considered a default.
  • Add a file and git pull from upstream is uneventful.

The test for whether the constraint is achievable is exactly the reading above. If the chart exposes what you need through values, you can stay additive. If it does not, you are choosing between a fork and a pull request, and knowing that on day one is much better than discovering it three overlays in.

The SQS gap is where I hit that boundary. The chart genuinely cannot inject that variable through any value, so staying additive meant patching after deploy. The alternative was a template edit, which would have made every future upstream sync a conflict on the file most likely to change. A kubectl set env is uglier and cheaper, and I would make the same call again while noting it belongs upstream as a four-line conditional.

The Checklist

Before deploying a chart you did not write:

  1. grep the templates for every top-level key you intend to set. No match means no consumer.
  2. Read _helpers.tpl for anything named endpoint, address, fullname, or url. That is where values get computed and where create flags change the contract.
  3. helm template with your actual overlays and read the ConfigMap and the Deployment env block.
  4. Diff the keys you set against the keys that appeared.
  5. Check whether any list you are overriding replaces something you wanted to keep.

None of it needs a cluster, and steps 1 and 3 alone would have saved me the one problem in this post.

The underlying point generalises past Helm. A chart is a program whose inputs are undeclared, and the only reliable way to learn its interface is to read what consumes the inputs. values.yaml is documentation. The templates are the truth.

Source


Related