Helm chart config pattern: Files.Get, tpl, and the config/ directory

Published: 2026-05-25

Helm has two ways to get external content into a chart: values.yaml and .Files.Get. For application config files — appsettings.json, nginx.conf, log4j.properties — the Files.Get approach beats dumping multi-line blobs into values. This post covers the pattern we settled on after shipping ~60 .NET microservices: store config as template files in config/, render secrets in CI, pack them into ConfigMaps with Files.Get, and force rolling restarts via checksum annotations.


The problem with values.yaml for config

A typical temptation is to put config inside values.yaml:

yaml# Don't do this
config:
  connectionString: "Server=db;Database=app;User=app;Password=secret"
  rabbitHost: "rabbit.infra"

Then in the template:

yamldata:
  appsettings.json: |
    {
      "ConnectionStrings": { "Default": "{{ .Values.config.connectionString }}" },
      "RabbitMq": { "Host": "{{ .Values.config.rabbitHost }}" }
    }

The problems:

  1. values.yaml ends up with 40+ keys for a moderately complex service.
  2. Multi-line values in YAML are painful (indentation, escape sequences).
  3. You can't use conditionals (if eq $env "Production") inside values.
  4. Secrets appear in plaintext in values-Production.yaml.

The Files.Get pattern

Instead, keep appsettings.json as a proper JSON file inside config/:

my-service/
  config/
    appsettings.json         ← rendered by consul-template at deploy time
    appsettings.ENV.json     ← per-env overrides, also rendered
  crt/
    YC-CA.pem                ← CA cert, rendered from Vault
  files/
    config.hcl               ← consul-template source for appsettings.json
    config-user.hcl          ← source for appsettings.ENV.json
    config-crt.hcl           ← source for YC-CA.pem

The configmap.yaml template loads the rendered files with Files.Get:

yamlapiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "my-service.fullname" . }}-app-settings
data:
  appsettings.json: |
{{ tpl (.Files.Get "config/appsettings.json") . | indent 4 }}
  appsettings.{{ .Values.aspnetcoreEnvironment }}.json: |
{{ tpl (.Files.Get "config/appsettings.ENV.json") . | indent 4 }}

Note tpl wrapping .Files.Get: this passes the file content through Helm's template engine, allowing {{ .Values.* }} references inside the JSON file. Useful for things like {{ .Values.replicaCount }} or {{ .Release.Namespace }} inside config.


Why indent 4 and not nindent

indent 4 adds 4 spaces to every line. nindent 4 adds a leading newline and then 4 spaces. For the multiline string block scalar (|) in YAML, indent is correct — the first line must start at the same indentation as the rest.

If you use nindent, you get an extra blank line at the top of the config value which is harmless for most parsers but looks wrong in kubectl get cm -o yaml.


CA certificate ConfigMap

The CA PEM is in its own ConfigMap so you can update it independently:

yamlapiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "my-service.fullname" . }}-ca-pemstore
data:
  YC-CA.pem: |
{{ .Files.Get "crt/YC-CA.pem" | indent 4 }}

No tpl here — the PEM is literal text, not a template.


Deployment volumeMounts

The two ConfigMaps mount into the container at the exact paths the .NET runtime expects:

yamlvolumeMounts:
  - name: config
    mountPath: /app/appsettings.json
    subPath: appsettings.json
  - name: config
    mountPath: /app/appsettings.{{ .Values.aspnetcoreEnvironment }}.json
    subPath: appsettings.{{ .Values.aspnetcoreEnvironment }}.json
  - name: ca-pemstore
    mountPath: /etc/ssl/certs/YC-CA.pem
    subPath: YC-CA.pem
    readOnly: true

volumes:
  - name: config
    configMap:
      name: {{ include "my-service.fullname" . }}-app-settings
  - name: ca-pemstore
    configMap:
      name: {{ include "my-service.fullname" . }}-ca-pemstore

subPath mounts a single key from the ConfigMap as a file, without replacing the entire directory. Without subPath, mounting at /app would hide all other files in /app.


Forced rolling restart on config change

The Deployment .spec.template.metadata.annotations contains a hash of each ConfigMap:

yamlmetadata:
  annotations:
    checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    checksum/configmap-ca: {{ include (print $.Template.BasePath "/capem-configmap.yaml") . | sha256sum }}

How it works:

  1. include (print $.Template.BasePath "/configmap.yaml") . renders the ConfigMap template to a string.
  2. sha256sum hashes it.
  3. The hash is stored in the Pod template annotation.
  4. When Vault secrets change and the pipeline re-renders config/appsettings.json, the ConfigMap content changes, so the hash changes, so the Pod template spec changes, so Kubernetes triggers a rolling update automatically.

Without this annotation, helm upgrade would update the ConfigMap but leave existing pods running with the old in-memory config (since Kubernetes doesn't restart pods when a mounted ConfigMap changes unless you use a projected volume with subPath — which also doesn't auto-reload).


The ENV placeholder

The appsettings.ENV.json file name uses the literal string ENV in git. During the consul-template -once call in CI, the output path contains the real environment name:

bashconsul-template \
  -template="chart/files/config-user.hcl:chart/config/appsettings.ENV.json" \
  -once

Wait — ENV stays as-is here. The aspnetcoreEnvironment value (Development, Production, etc.) is passed to the deployment via --set aspnetcoreEnvironment=${APP_ENV}. The configmap template then uses {{ .Values.aspnetcoreEnvironment }} to name the key, so the container gets /app/appsettings.Production.json mounted at runtime.


Helm lint

helm lint validates the chart templates. Since config/appsettings.json is git-ignored (it's a render artifact), add a stub file so lint passes:

json{}

Commit this placeholder as config/appsettings.json. consul-template will overwrite it during deploy. helm lint and local helm template invocations see an empty JSON object, which is valid.


Summary

Concern Solution
Secrets in config consul-template renders from Vault before helm upgrade
Config file format Raw JSON/YAML in config/, not embedded in values.yaml
Helm template access Files.Get + optional tpl
Mount into pod subPath volumeMount per file
Rolling update on change checksum/* annotation in Pod template
lint/template locally Stub config/ files in git