Reducing code duplication and overhead with Helm Library Charts
by Jens Heremans
At one of our customers we manage several applications composed of many micro-services, around 50 at the time of writing. We deploy these services on Kubernetes using Helm charts. Each services is built and packaged together with its own Helm chart. So each version of the services comes with an identically versioned helm chart. While that approach has served us quite well over the years, we started noticing a few challenges as our applications kept growing.
- Over 90% of the Helm templating code across all charts is identical.
- Any change to a Helm chart requires building a new version of the micro-service.
- Structural changes to the Helm templates must be propagated to every repository.
Given these challenges, we decided it was time to explore whether our setup could be improved.
What We’re Trying to Achieve
In an effort to make our setup more efficent we focused on 3 main objectives:
1. Reduce Code Duplication
Most of our Helm charts shared the same templating logic. This not only bloated our code-base but also made it harder to maintain consistency. We wanted a centralized solution that could provide reusable building blocks for our charts.
2. Lower the Maintenance Overhead
Every structural change in the Helm templates required pushing changes to every micro-service repository and rebuilding each one. Our goal was to reduce the operational burden on developers.
3. Ensure Consistency Across Charts
Despite best efforts, differences between individual service charts started creeping in, especially when teams made service-specific tweaks. These inconsistencies made debugging deployments harder and introduced potential for human error. We aimed to standardize chart structure across services, making deployments more predictable and easier to manage.
Evaluating Our Options
Before jumping into a new solution, we explored a few common patterns for managing Helm charts at scale. Ultimately, we rejected two approaches in favor of one that better fit our needs.
❌ Monolithic Helm Chart
One option was to use a single monolithic Helm chart that could deploy all services. However, this approach separates the chart from the application code, making the setup less intuitive for developers. It also would have required significant changes to our CI/CD pipelines to support this new structure, something we wanted to avoid.
❌ Centralized Reusable Chart
Another approach we considered was maintaining a single, centralized Helm chart and referencing it from each micro-service repository with just a unique values.yaml
file. While this would reduce duplication, it proved too rigid. We often need to define service-specific templates (like custom config maps or jobs), and this approach didn’t allow that very easily.
✅ Helm Library Chart
The solution we ultimately chose was to use a Helm library chart. This approach provided the best balance between reusability and flexibility:
- Enables us to define and maintain a shared set of reusable templates.
- Allows services to extend or override templates as needed.
- Requires no changes to our existing CI/CD pipelines.
- Supports global sane defaults, reducing the need for repetitive values in each chart.
We can maintain a clean and DRY chart structure, while still giving individual teams the power to customize where needed.
Using Library charts
With Helm library charts, we can centralize and reuse common template logic across all our service charts. Instead of duplicating the same boilerplate across 50+ Helm charts, we define reusable snippets, called definitions, in a shared library chart. Then, individual charts can reference these definitions directly, keeping them minimal and consistent.
Now how does this all work?
1. Initial setup
Define a Library Chart
Start by creating a new Helm chart with type: library
. This chart won’t produce any Kubernetes objects on its own.t’s meant to be imported and used by other charts.
# my-library-chart/Chart.yaml
apiVersion: v2
name: my-library-chart
description: A library of Helm templates to build our application charts
type: library
version: 0.1.0
Prefix each template file with an underscore. This is required to prevent Helm from rendering yaml within the library itself.
The folder structure should look like this:
my-library-chart/
├── templates/
│ ├── _deployment.tpl
│ ├── _service.tpl
│ └── ...
├── Chart.yaml
└── values.yaml
Add the Library Chart as a Dependency
In your micro-service Helm chart, include the library chart as a dependency:
# example-service/Chart.yaml
apiVersion: v2
name: example-service
description: A Helm chart for our microservice
version: 0.1.0
appVersion: "1.0"
dependencies:
- name: my-library-chart
version: 0.1.0
repository: link-to-your-repository.com
Then, run helm dependency update
to pull in the library chart.
💡 NOTE: During testing you can also point to the folder on your filesystem containing the library chart so that you don’t need to push to your repository every time. For example: repository: file:///path/to/your/library/chart
2. Creating Library Definitions
As mentioned before, the template filenames should have an underscore (_) prefix. This tells Helm not to render them directly, but to make them available for inclusion.
We can then use the define
function to create definitions.
Here’s an example of a simple deployment resource:
# my-library-chart/templates/_deployment.yaml
{{- define "my-library-chart.defaultDeployment" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Values.name }}
spec:
replicas: {{ .Values.replicaCount | default 1 }}
selector:
matchLabels:
app: {{ .Values.name }}
template:
metadata:
labels:
app: {{ .Values.name }}
spec:
containers:
- name: {{ .Values.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
{{- end }}
Helm will not render YAML from this template directly. Instead, you can reference the my-library-chart.defaultDeployment
definition from another template file, without the underscore prefix, to include and render it as part of that chart.
You can create as many template files and definitions as needed. As well as multiple resources of the same kind.
3. Referencing Library Templates
In your application chart, you can now reference those library definitions using the include function:
# example-service/templates/deployment.yaml
{{- include "my-library-chart.defaultDeployment" . }}
Given the following values file provided in your application helm chart:
# example-service/values.yaml
name: example-service
replicaCount: 2
image:
repository: mycompany/example-service
tag: v1.2.3
service:
port: 8080
Will render the example deployment definition as:
apiVersion: apps/v1
kind: Deployment
metadata:
name: example-service
spec:
replicas: 2
selector:
matchLabels:
app: example-service
template:
metadata:
labels:
app: example-service
spec:
containers:
- name: example-service
image: "mycompany/example-service:v1.2.3"
ports:
- containerPort: 8080
4. Optional: Setting Default Values
In order to reduce the amount of code in each application chart you can also set default values. These are optionally imported and can be overridden by the local chart’s values.
To configure defaults inside the library chart, define them under the top-level exports
key.
# my-library-chart/values.yaml
exports:
global:
replicaCount: 1
service:
port: 80
Then, in your application chart, import these values using the import-values
field in the Chart.yaml
file. You can selectively import values by specifying the key you want to include. This allows you to group and reuse sets of default values while keeping imports minimal and relevant.
For example, to import the global
values from the snippet above:
# example-service/Chart.yaml
...
dependencies:
- name: my-library-chart
version: 0.1.0
repository: link-to-your-repository.com
import-values:
- global
These values will be imported with global as the root, so your application chart will inherit them as if they were defined locally:
replicaCount: 1
service:
port: 80
Meaning you can reference these values in your templates as normal, e.g. {{ .Values.service.port }}
, and override them in any values file passed to Helm.
You can find more information on default values in Helm’s docs here.
5. Optional: Shared Helpers
To keep things even more DRY, you can also define reusable helper functions in a .tpl
file in your library chart:
# my-library-chart/templates/_helpers.tpl
{{- define "common.name" -}}
{{ .Chart.Name }}
{{- end }}
{{- define "common.fullname" -}}
{{ printf "%s-%s" .Release.Name .Chart.Name }}
{{- end }}
You can then reference these helpers in any template using the include
function. Extending the previous example deployment:
# my-library-chart/templates/_deployment.yaml
{{- define "my-library-chart.defaultDeployment" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "common.fullname" . }}
spec:
replicas: {{ .Values.replicaCount | default 1 }}
selector:
matchLabels:
app: {{ include "common.name" . }}
template:
metadata:
labels:
app: {{ include "common.name" . }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
{{- end }}
Shared helpers can include many useful functions, such as:
- Standard sets of annotations or labels
- Common metadata blocks
- Reusable container definitions
- Logic for parsing values to generate labels or names
💡 NOTE: The Helm documentation on library charts includes a more advanced helper function called mylibchart.util.merge
. It allows you to override parts of your library definitions from within the application chart.
6. Optional: Creating “base” Templates
As your library chart grows, you may find yourself repeatedly combining the same helper functions, default values, and templates for similar types of applications. To make reuse even easier, you can group these into base templates. Instead of having each application chart include multiple individual library chart definitions (e.g. deployment, service, labels, annotations, etc.), you can define a single base definition that includes everything needed for that type of service. This way, the application chart only needs to reference one top-level template.
One way to use this in practice is to create a base template for each application type like a java spring-boot miscro-service or a nodejs webapp.
To keep it manageable you could structure your folder like this:
my-library-chart/
├── values.yaml
├── Chart.yaml
├── templates/
│ ├── _helpers.tpl
│ ├── java/
│ │ ├── _base.yaml
│ │ ├── _configmap.yaml
│ │ ├── _deployment.yaml
│ │ ├── _service.yaml
│ │ ├── ...
│ ├── nodejs/
│ │ ├── _base.yaml
│ │ ├── _configmap.yaml
│ │ ├── _deployment.yaml
│ │ ├── _service.yaml
│ └── ...
Make sure all your definitions have unique names. For example you could define your java deployment as my-library-chart.java.deployment
and your nodejs deployment as my-library-chart.nodejs.deployment
.
Then we can create our base java template:
# my-library-chart/templates/java/_base.yaml
{{- define "my-library-chart.base.java" -}}
{{ include "my-library-chart.java.deployment" . }}
{{ include "my-library-chart.java.configmap" . }}
{{ include "my-library-chart.java.service" . }}
{{- end }}
Your application chart can then include just the base template in its custom chart template:
# example-service/templates/base.yaml
{{ include "my-library-chart.base.java" . }}
And that’s it! just a single line to create your entire helm chart.
💡 NOTE: If needed, you can always add any other template files that do not reference any of your libraries inside your application helm chart. Some resources might be unique and not required anywhere else, so there’s little benefit in including those to your library.
Conclusion
Getting started with Helm Library Charts definitely came with a learning curve, but in the end, it proved well worth the effort. We’ve made significant improvements over our previous setup.
Results after switching to library charts:
- Reduced code duplication by eliminating over 25,000 lines of code across all repositories
- Drastically lowered the overhead of making changes to Helm charts
- Improved consistency across charts, making debugging easier and reducing the risk of human error
- Maintained flexibility, charts are still easy to extend when needed
There’s certainly more we could explore with Helm libraries, but for now, we’re satisfied with where we’ve landed. You can go quite deep with this pattern, but we recommend keeping it opinionated. You likely don’t need to accommodate every edge case, so KISS (Keep It Simple, Stupid).
If you’re looking to get started with your own library chart and want some examples, check out Helmet or the (deprecated but still useful) Common Helm Helper Chart.
References
Helm Docs – https://helm.sh/docs
Common Helm Helper Chart – https://github.com/helm/charts/tree/master/incubator/common
Helmet (example library) – https://github.com/companyinfo/helm-charts/tree/main/charts/helmet