Helm Templates in Files Explained: Customize ConfigMaps and Secrets Content

Helm Templates in Files Explained: Customize ConfigMaps and Secrets Content

If you have ever built a Helm chart that includes configuration files, scripts, or property files inside a ConfigMap or Secret, you have probably hit the same wall: the default templating engine only processes YAML files inside the templates/ directory. Everything else is treated as static content.

This is a problem because real-world applications rarely deploy with hardcoded configuration. You need environment-specific values in your .properties files, tokens in your JSON configs, or dynamic hostnames in your shell scripts. Helm provides three core functions to handle this: .Files.Get, .Files.Glob, and tpl. Each solves a different piece of the puzzle, and combining them is where things get powerful.

This guide covers every practical pattern you will need, from the simplest .Files.Get call to the advanced tpl + .Files.Glob combination that gives you full templating inside external files. For a broader look at Helm packaging, see our definitive Helm package management guide.

Understanding the Helm Chart File Structure

Before diving into the functions, it is important to understand what Helm considers a “file” and where it can access them. A typical chart looks like this:

my-chart/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── configmap.yaml
│   ├── secret.yaml
│   └── deployment.yaml
├── config/
│   ├── app.properties
│   ├── logging.json
│   └── init.sh
└── files/
    └── zones.json

Files inside templates/ go through the full Helm template engine. Files outside it (like config/app.properties or files/zones.json) are accessible via the .Files object, but they are not templated automatically. This is the key distinction that catches most people off guard.

.Files.Get: Reading a Single File

The .Files.Get function reads the content of a specific file by its path, relative to the chart root. This is the simplest way to include file content in a ConfigMap or Secret.

Basic ConfigMap Example with .Files.Get

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-app-config
  namespace: {{ .Release.Namespace }}
data:
  app.properties: |
{{ .Files.Get "config/app.properties" | indent 4 }}

This reads config/app.properties from the chart root and injects it into the ConfigMap, preserving the original content. The indent 4 ensures correct YAML indentation.

Secret Example with .Files.Get and b64enc

For Secrets, Kubernetes expects base64-encoded values in the data field. Combine .Files.Get with b64enc:

apiVersion: v1
kind: Secret
metadata:
  name: {{ .Release.Name }}-tls-config
  namespace: {{ .Release.Namespace }}
type: Opaque
data:
  tls.crt: {{ .Files.Get "certs/tls.crt" | b64enc }}
  tls.key: {{ .Files.Get "certs/tls.key" | b64enc }}

.Files.Get Path Limitations: Why “../” Does Not Work

A very common question is whether you can use .Files.Get to access files outside the chart directory, for example .Files.Get "../shared/config.yaml". The answer is no. Helm restricts file access to the chart root for security reasons. Any path that tries to escape the chart directory with ../ will silently return an empty string.

If you need to share files between charts, the recommended patterns are:

  • Use a library chart or dependency that includes the shared files
  • Copy shared files into each chart during your CI/CD pipeline before packaging
  • Pass the content through values.yaml using a parent chart

Also note that files inside the templates/ directory and Chart.yaml itself are not accessible through .Files. Only files that are packaged with the chart and not in templates/ can be read.

.Files.Glob: Working with Multiple Files

When you have multiple configuration files to include, .Files.Glob lets you match files using glob patterns and iterate over them. This is especially useful when your chart ships with several config files that all need to end up in the same ConfigMap.

.Files.Glob with .AsConfig Example: ConfigMap from Multiple Files

The .AsConfig helper takes a set of matched files and formats them as ConfigMap data entries, where each filename becomes the key and the file content becomes the value:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-all-configs
  namespace: {{ .Release.Namespace }}
data:
{{ (.Files.Glob "config/*").AsConfig | indent 2 }}

If your config/ directory contains app.properties, logging.json, and init.sh, the result would be:

data:
  app.properties: |
    server.port=8080
    db.host=postgres
  logging.json: |
    {"level": "info", "format": "json"}
  init.sh: |
    #!/bin/bash
    echo "Initializing..."

This is the cleanest approach when you want to include all files from a directory without modifying their content.

.Files.Glob with .AsSecrets Example

.AsSecrets works identically to .AsConfig, but automatically base64-encodes each file’s content for use in Secret resources:

apiVersion: v1
kind: Secret
metadata:
  name: {{ .Release.Name }}-credentials
  namespace: {{ .Release.Namespace }}
type: Opaque
data:
{{ (.Files.Glob "secrets/*").AsSecrets | indent 2 }}

Iterating with range: The Explicit Approach

For more control over how each file is processed, you can iterate explicitly using range:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-scripts
  namespace: {{ .Release.Namespace }}
data:
  {{- range $path, $_ := .Files.Glob "scripts/*.sh" }}
  {{ base $path }}: |
{{ $.Files.Get $path | indent 4 }}
  {{- end }}

Notice two important details here. First, base $path extracts just the filename (e.g., init.sh) from the full path (scripts/init.sh). Second, inside a range loop the context changes, so you must use $. (dollar-dot) to access the root scope when calling $.Files.Get.

The tpl Function: Full Templating Inside External Files

This is where things get really interesting. The .Files.Get and .Files.Glob functions read file content as-is. If your app.properties file contains {{ .Values.database.host }}, it will be included literally as that string, not replaced with the actual value.

The tpl function solves this by passing a string through the Helm template engine. When you combine tpl with .Files.Get, your external files get the same templating power as files inside templates/.

tpl + .Files.Get: Templated ConfigMap

Consider this config/app.properties file in your chart:

# config/app.properties
server.port={{ .Values.app.port | default 8080 }}
server.host={{ .Values.app.host }}
database.url=jdbc:postgresql://{{ .Values.database.host }}:{{ .Values.database.port }}/{{ .Values.database.name }}
database.pool.size={{ .Values.database.poolSize | default 10 }}
logging.level={{ .Values.logging.level | default "INFO" }}

And the corresponding template that processes it:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-app-config
  namespace: {{ .Release.Namespace }}
data:
  app.properties: |
{{ tpl (.Files.Get "config/app.properties") . | indent 4 }}

The tpl function takes two arguments: the string to process and the context (.). It runs the content through the template engine, replacing all {{ .Values.* }} references with actual values. The result is a fully dynamic configuration file.

tpl + .Files.Glob: Templating Multiple Files

To template every file matched by a glob pattern, combine the range iteration with tpl:

apiVersion: v1
kind: Secret
metadata:
  name: {{ .Release.Name }}-dynamic-secrets
  namespace: {{ .Release.Namespace }}
type: Opaque
data:
  {{- range $path, $_ := .Files.Glob "secrets/*.json" }}
  {{ base $path }}: {{ tpl ($.Files.Get $path) $ | b64enc }}
  {{- end }}

This iterates over all .json files in the secrets/ directory, passes each through the template engine (so {{ .Values.* }} references are resolved), base64-encodes the result, and includes it in the Secret. This is the most powerful pattern for managing multiple dynamic configuration files.

Common Pitfalls and Troubleshooting

Working with .Files in Helm can produce confusing errors or silent failures. Here are the most common issues and how to fix them.

.Files.Get Returns Empty String

If .Files.Get returns nothing, check these three things:

  • Wrong path: The path is relative to the chart root, not to the templates directory. Use .Files.Get "config/app.properties", not .Files.Get "../config/app.properties".
  • File excluded by .helmignore: Check your .helmignore file. If it matches the file’s path, the file will not be packaged with the chart and .Files.Get will return empty.
  • File in templates/ directory: Files inside templates/ are not accessible via .Files. Move them to a different directory.

YAML Indentation Errors

The most frequent rendering error is incorrect indentation. Always use indent N (or nindent N) when including file content in YAML. The difference between indent and nindent is that nindent adds a newline before the content, which is cleaner when using {{- to trim whitespace:

# Using indent (requires | on the previous line)
data:
  app.properties: |
{{ .Files.Get "config/app.properties" | indent 4 }}

# Using nindent (cleaner, self-contained)
data:
  app.properties: {{ .Files.Get "config/app.properties" | nindent 4 }}

Chart Size Limit

Helm charts stored in Kubernetes as Secrets or ConfigMaps are subject to the 1 MB limit imposed by etcd. If your chart includes many large files, you may hit this limit during helm install. The error typically reads release: invalid or etcd: request is too large. In that case, consider mounting files via persistent volumes or external config management instead of embedding them in the chart.

Context Issues Inside range Loops

Inside a range block, . refers to the current iteration item, not the root context. This means .Files.Get will fail. Use $. to access the root context:

# Wrong: . is the loop item, not the root context
{{- range $path, $_ := .Files.Glob "config/*" }}
  {{ .Files.Get $path }}  {{/* This will fail */}}
{{- end }}

# Correct: use $ to access root context
{{- range $path, $_ := .Files.Glob "config/*" }}
  {{ $.Files.Get $path }}  {{/* This works */}}
{{- end }}

Real-World Pattern: Multi-Environment Configuration

A practical pattern that combines everything above is organizing environment-specific configuration files and selecting them dynamically:

# Chart structure
my-chart/
├── config/
│   ├── application.yaml      # Shared base config
│   ├── db-pool.properties    # Database pool settings
│   └── logging.xml           # Log4j/Logback config
├── templates/
│   └── configmap.yaml
└── values.yaml

The ConfigMap template processes all config files with templating:

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-config
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "my-chart.labels" . | nindent 4 }}
data:
  {{- range $path, $_ := .Files.Glob "config/*" }}
  {{ base $path }}: |
{{ tpl ($.Files.Get $path) $ | indent 4 }}
  {{- end }}

This way, every file in config/ is automatically included, templated, and properly formatted. Adding a new configuration file is as simple as dropping it into the directory. No template changes required.

Quick Reference: .Files Functions Summary

Here is a quick reference of all file-related functions available in Helm:

FunctionWhat it doesExample
.Files.GetReturns the content of a single file as a string.Files.Get "config/app.yaml"
.Files.GlobReturns all files matching a glob pattern.Files.Glob "config/*.json"
.AsConfigFormats matched files as ConfigMap data entries(.Files.Glob "config/*").AsConfig
.AsSecretsFormats matched files as base64-encoded Secret data(.Files.Glob "secrets/*").AsSecrets
tplPasses a string through the Helm template enginetpl (.Files.Get "f.yaml") .
.Files.LinesReturns file content as a list of lines.Files.Lines "config/hosts.txt"
.Files.AsBytesReturns file content as a byte array.Files.Get "bin/tool" \| b64enc

Conclusion

Helm file handling follows a clear escalation path depending on what you need. Use .Files.Get when you need a single file as-is. Use .Files.Glob with .AsConfig or .AsSecrets when you have multiple files that need no modification. And use tpl combined with either function when your files need dynamic values from values.yaml.

The most common mistake is forgetting that .Files only reads files — it does not template them. The moment you need {{ .Values.* }} inside an external file, tpl is the function you are looking for. For more Helm patterns and advanced tips, explore our guides on Helm hooks, Helm loops, and Helm dependencies.

Frequently Asked Questions

Can .Files.Get access files outside the chart directory?

No. Helm restricts .Files.Get to files within the chart root directory. Paths containing ../ will silently return an empty string. This is a security constraint to prevent charts from reading arbitrary files from the filesystem. If you need shared files across charts, use library charts, copy files during CI/CD, or pass content through parent chart values.

What is the difference between .AsConfig and .AsSecrets in Helm?

.AsConfig formats files as plain text ConfigMap data entries (key: filename, value: file content). .AsSecrets does the same but automatically base64-encodes each file’s content, which is required for the data field in Kubernetes Secret resources. Both are called on the result of .Files.Glob.

How do I use Helm values inside a properties file or JSON config?

Use the tpl function. Instead of .Files.Get "config/file.json", use tpl (.Files.Get "config/file.json") .. This passes the file content through the Helm template engine, so any {{ .Values.* }} references in the file will be resolved against your chart’s values.

Why does .Files.Get return an empty string?

Three common causes: the file path is wrong (paths are relative to the chart root, not the templates directory), the file is excluded by .helmignore, or the file is inside the templates/ directory which is not accessible via .Files. Run helm template locally and check the output to debug.

Is there a size limit for files included via .Files.Get?

Helm itself does not impose a file size limit, but the packaged chart is stored as a Kubernetes Secret or ConfigMap which is subject to the etcd 1 MB size limit. If your chart with all its files exceeds this, helm install will fail. For large files, consider external config management or persistent volumes instead of embedding them in the chart.

Frequently Asked Questions

What does .Files.Get do in Helm?

.Files.Get "path" reads a file from the chart (path relative to the chart root, not the templates directory) and returns its content as a string — typically piped into a ConfigMap or Secret. Binary files use .Files.GetBytes.

Why does .Files.Get return an empty string?

Three usual causes: the path is written relative to templates/ instead of the chart root; the file is excluded by .helmignore; or you are trying to read from templates/ itself, which .Files deliberately cannot access. Files outside the chart (../) are also unreachable by design.

How do I load multiple files with Files.Glob?

{{ range $path, $file := .Files.Glob "configs/*.yaml" }} iterates matching files; combine with .AsConfig to emit them as ConfigMap entries in one line: {{ (.Files.Glob "configs/*").AsConfig | indent 2 }}.

Can Helm templates render the content of loaded files?

Yes — pipe through tpl: {{ tpl (.Files.Get "config.tpl") . }} executes template directives inside the file with the chart’s context. This is the standard pattern for config files that need access to .Values.

Helm Dependencies Explained: How Chart Dependencies Work in Helm

Helm Dependencies Explained: How Chart Dependencies Work in Helm

Helm Dependency is a critical part of understanding how Helm works as it is the way to establish relationships between different helm packages. We have talked a lot here about what Helm is, and some topics around that, and we even provided some tricks if you create your charts.

Related reading: Helm chart management: centralized repos vs per-service charts.

Related reading: Helm version constraints: tilde, caret and SemVer ranges.

Understanding chart dependencies is crucial for building scalable Helm architectures. Explore more Helm patterns and best practices in our comprehensive Helm guide.

So, as commented, Helm Chart is nothing more than a package that you put around the different Kubernetes objects that need to be deployed for your application to work. The usual comparison is that it is similar to a Software Package. When you install an application that depends on several components, all of those components are packaged together, and here is the same thing.

What is a Helm Dependency?

A Helm Dependency is nothing more than the way you define that your Chart needs another chart to work. For sure, you can create a Helm Chart with everything you need to deploy your application, but something you would like to split that work into several charts just because they are easy to maintain or the most common use case because you want to leverage another Helm Chart that is already available.

One use case can be a web application that requires a database, so you can create on your Helm Chart all the YAML files to deploy your web application and your Database in Kubernetes, or you can have your YAML files for your web application (Deployment, Services, ConfigMaps,…) and then say: And I need a database and to provide it I’m going to use this chart.

This is similar to how it works with the software packages in UNIX systems; you have your package that does the job, like, for example, A, but for that job to be done, it requires the library L, and to ensure that when you are installing A, Library L is already there or if not it will be installed you declare that your application A depends on Library L, so here is the same thing. You declare that your Chart depends on another Chart to work. And that leaves us to the next point.

How do we declare a Helm Dependency?

This is the next point; now that we understand what a Helm Dependency is conceptually and we have a use case, how can we do that in our Helm Chart?

All the work is done in the Chart.yml file. If you remember, the Chart.yml file is the file where you declare all the metadata of your Helm Chart, such as the name, the version of the chart, the application version, location URL, icon, and much more. And usually has a structure like this one:

apiVersion: v2
name: MyChart
description: My Chart Description
type: application
version: 0.2.0
appVersion: "1.16.0"

So here we can add a section dependencies and, in that section is where we are going to define the charts that we depend on. As you can see in the snippet below:

apiVersion: v2
name: MyChart
description: My Chart Description
type: application
version: 0.2.0
appVersion: "1.16.0"
dependencies:
- name: Dependency
  version: 1.0.0
  repository: "file:///location_of_my_chart"

Here we are declaring Dependency as our Helm Dependency. We specify the version that we would like to use (similar to the version we say in our chart), and that will help us to ensure that we will provide the same version that has been tested as part of the resolution of the dependency and also the location using an URL that can be an external URL is this is pointing to a Helm Chart that is available on the internet or outside your computer or using a File Path in case you are pointing to a local resource in your machine.

That will do the job of defining the helm dependency, and this way, when you install your chart using the command helm install, it will also provide the dependence.

How do I declare a Helm Conditional Dependency?

Until now, we learned how to declare a dependency, and each time I provision my application, it will also provide the dependence. But usually, we would like to have a fine-grained approach to that. Imagine the same scenario as above: We have our Web Application that depends on the Database, and we have two options, we can provision the database as part of the installation of the web application, or we can point to an external database and in that case, it makes no sense to provision the Helm Dependency. How can we do that?

So, easy, because one of the optional parameters you can add to your dependency is condition and do exactly that, condition allow you to specify a flag in your values.yml that in the case is equal to true, it will provide the dependency but in the case is equal to false it will skip that part similar to the snippet shown below:

 apiVersion: v2
name: MyChart
description: My Chart Description
type: application
version: 0.2.0
appVersion: "1.16.0"
dependencies:
- name: Dependency
  version: 1.0.0
  repository: "file:///location_of_my_chart"
  condition: database.enabled 

And with that, we will set the enabled parameter under database in our values.yml to true if we would like to provision it.

How do I declare a Helm Dependency With a Different version?

As shown in the snippets above, we offer that when we declare a Helm Dependency, we specify the version; that is a safe way to do it because it ensures that any change done to the helm chart will not affect your package. Still, at the same time, you cannot be aware of security fixes or patches to the chart that you would like to leverage in your deployment.

To simplify that, you have the option to define the version in a more flexible way using the operator ~ in the definition of the version, as you can see in the snippet below:

apiVersion: v2
name: MyChart
description: My Chart Description
type: application
version: 0.2.0
appVersion: "1.16.0"
dependencies:
- name: Dependency
  version: ~1.0.0
  repository: "file:///location_of_my_chart"
  condition: database.enabled 

This means that any patch done to the chart will be accepted, so this is similar that this chart will use the latest version of 1.0.X. Still, it will not use the 1.1.0 version, so that allows to have more flexibility, but at the same time keeping things safe and secured in case of a breaking change on the Chart you depend on. This is just one way to define that, but the flexibility is enormous as the Chart versions use “Semantic Versions,” You can learn and read more about that here: https://github.com/Masterminds/semver.

Helm Loops Explained: A Practical Helm Hack to Avoid Deployment Issues

Helm Loops Explained: A Practical Helm Hack to Avoid Deployment Issues

Introduction



When working with complex Helm deployments, mastering loops is just one piece of the puzzle. For a comprehensive understanding of Helm from fundamentals to advanced patterns, check out our complete Helm Charts & Kubernetes Package Management Guide.

Helm Charts are becoming the default de-factor solution when you want to package your Kubernetes deployment to be able to distribute or quickly install it in your system.

Defined several times as the apt for Kubernetes for its similarity with the ancient package manager from GNU/Linux Debian-like distributions, it seems to continue to grow in popularity each month compared with other similar solutions even more tightly integrated into Kubernetes such as Kustomize, as you can see in the Google Trends picture below:

Helm Loops: Helm Charts vs Kustomize

But creating these helm charts is not as easy as it shows. If you already have been on the work of doing so, you probably get stuck at some point, or you spend a lot of time trying to do some things. If this is the first time you are creating one or trying to do something advanced, I hope all these tricks will help you on your journey. Today we are going to cover one of the most important tricks, and those are Helm Loops.

Helm Loops Introduction

If you see any helm chart for sure, you will have a lot of conditional blocks. Pretty much everything is covered under an if/else structure based on the values.yml files you are creating. But this gets a little bit tricky when we talk about loops. But the great thing is that you will have the option to execute a helm loop inside your helm charts using the rangeprimitive.

How to create a Helm Loop?

The usage of the rangeprimitive is quite simple, as you only need to specify the element you want to iterate across, as shown in the snippet below:

{{- range .Values.pizzaToppings }}
- {{ . | title | quote }}
{{- end }}    

This is a pretty simple sample where the yaml will iterate over the values that you have assigned to the pizzaToppings structure in your values.yml

There are some concepts to keep in mind in this situation:

  • You can easily access everything inside this structure you are looping across. So, if pizza topping has additional fields, you can access them with something similar to this:
{{- range.Values.pizzaToppings }}
- {{ .ingredient.name | title | quote }}
{{- end }}    

And this will access a structure similar to this one in your values.yml:

 pizzaToppings:
	- ingredient:
		name: Pinneaple
		weight: 3

The good thing is that you can access their underlying attribute without replicating all the parent hierarchy until you reach the looping structure because inside the range section, the scope has changed. We will refer to the root of each element we are iterating across.

How to access parent elements inside a Helm Loop?

In the previous section, we covered how easily we can access the inner attribute inside the loop structure because of the change of scope, which also has an issue. In case I want to access some element in the parent of my values.yml file or somewhere outside the structure, how can I access them?

The good thing is that we also have a great answer to that, but you can get there. We need to understand a little bit about the scopes in Helm.

As commented, . refers to the root element in the current scope. If you have never defined a range section or another primitive that switches the context, .always will refer to the root of your values.yml. That is why when you see a helm chart, you see all the structures with the following way of working: .Values.x.y.z, but we already have seen that when we have a range section, this is changing, so this is not a good way.

To solve that, we have the context $ that constantly refers to the root of the values. ymlno matter which one is the current scope. So that means that if I have the following values.yml:

base:
	- type: slim 
pizzaToppings:
	- ingredient:
		name: Pinneaple
		weight: 3
	- ingredient:
		name: Apple
		weight: 3

And I want to refer to the base type inside the range section similar to before I can do it using the following snippet:

{{- range .Values.pizzaToppings }}
- {{ .ingredient.name | title | quote }} {{ $.Values.base.type }}
{{- end }}    

That will generate the following output:

 - Pinneaple slim
 - Apple slim

So I hope this helm chart trick will help you with the creation, modification, or improvement of your upgraded helm charts in the future by using helm loops without any further concern!