Library Charts and the Death of Copy-Paste Templates
You have 5 charts that all need the same labels, annotations, and helper functions. Copy-paste isn’t the answer.
I know this because I did it. For about 18 months, every chart in our org had its own _helpers.tpl file with the same 40 lines of label definitions, the same fullname logic, and the same selector label pattern. When we changed our labeling convention (adding a team label), I updated 12 charts by hand. Took an afternoon. Missed two. Found out when the monitoring dashboards broke.
Library charts solve this. They’ve been available since Helm 3.6, and as of Helm 3.16 (the current release), they’re mature and well-supported. If you maintain multiple charts that share common logic, this is the single most impactful structural change you can make.
What a library chart is
A library chart is a Helm chart that contains only template definitions. It doesn’t deploy anything. You can’t install it. It exists solely to provide reusable templates that other charts can consume.
The distinction is in Chart.yaml:
apiVersion: v2
name: common
description: Shared Helm templates for our organization
type: library
version: 1.2.0
The type: library field is what makes it a library chart. Without it (or with type: application), Helm treats it as a regular chart that can be installed.
Creating a library chart
Let’s build one from scratch. The typical use case: shared labels, common annotations, and utility templates that every chart in your org needs.
mkdir -p common/templates
The Chart.yaml:
apiVersion: v2
name: common
description: Shared Helm library chart
type: library
version: 1.0.0
No values.yaml needed (though you can include one if your templates reference default values).
Now the templates. Start with templates/_labels.tpl:
{{/*
Standard labels for all resources.
*/}}
{{- define "common.labels" -}}
helm.sh/chart: {{ include "common.chart" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ include "common.partOf" . }}
app.kubernetes.io/team: {{ .Values.team | default "platform" }}
{{- end }}
{{/*
Chart label.
*/}}
{{- define "common.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end }}
{{/*
Part-of label. Defaults to chart name, can be overridden.
*/}}
{{- define "common.partOf" -}}
{{- default .Chart.Name .Values.partOf -}}
{{- end }}
Then templates/_fullname.tpl:
{{/*
Fully qualified app name.
Usage: {{ include "common.fullname" . }}
*/}}
{{- define "common.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
And templates/_annotations.tpl:
{{/*
Standard annotations.
*/}}
{{- define "common.annotations" -}}
app.kubernetes.io/created-by: helm
helm.sh/revision: {{ .Release.Revision | quote }}
{{- end }}
And a utility template in templates/_utils.tpl:
{{/*
Merge multiple dicts. Usage: include "common.merge" (list $dict1 $dict2)
The last dict wins on conflicts.
*/}}
{{- define "common.merge" -}}
{{- $result := dict -}}
{{- range . -}}
{{- $result = mergeOverwrite $result . -}}
{{- end -}}
{{- toYaml $result -}}
{{- end -}}
{{/*
Convert a map to environment variables.
Usage: include "common.envFromMap" (dict "prefix" "APP_" "map" .Values.extraEnv)
*/}}
{{- define "common.envFromMap" -}}
{{- $prefix := .prefix -}}
{{- range $key, $value := .map }}
- name: {{ $prefix }}{{ $key }}
value: {{ $value | quote }}
{{- end -}}
{{- end -}}
The final structure:
common/
Chart.yaml
templates/
_labels.tpl
_fullname.tpl
_annotations.tpl
_utils.tpl
Using a library chart as a dependency
To consume the library, add it as a dependency in your application chart’s Chart.yaml:
apiVersion: v2
name: my-api
version: 0.3.0
type: application
dependencies:
- name: common
version: "1.x.x"
repository: "oci://ghcr.io/myorg/charts"
Run helm dependency update and the library chart gets downloaded into charts/common/.
Now in your application’s templates, you can call the library’s named templates:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "common.fullname" . }}
labels:
{{- include "common.labels" . | nindent 4 }}
app.kubernetes.io/component: api
annotations:
{{- include "common.annotations" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "common.fullname" . }}
template:
metadata:
labels:
{{- include "common.labels" . | nindent 8 }}
app.kubernetes.io/name: {{ include "common.fullname" . }}
app.kubernetes.io/component: api
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
{{- if .Values.extraEnv }}
env:
{{- include "common.envFromMap" (dict "prefix" "API_" "map" .Values.extraEnv) | nindent 12 }}
{{- end }}
Every label, annotation, and naming convention comes from the library. Change the library once, and every chart that depends on it picks up the change on the next helm dependency update and redeploy.
Include vs template
This distinction matters more when working with library charts. Use include, not template.
# This works but you can't pipe the output
{{- template "common.labels" . }}
# This works AND you can pipe
{{- include "common.labels" . | nindent 4 }}
The template action writes directly to the output. The include function returns a string that you can pipe through nindent, indent, trim, or any other function. In practice, include is almost always what you want.
There’s one case where template is useful: when you’re defining a template inside another template (nested definitions). But that’s rare and usually a sign you should refactor.
Values merging with library charts
Library charts can reference .Values, but the values come from the consuming chart, not the library. This is important to understand.
If your library template does:
{{- define "common.labels" -}}
app.kubernetes.io/team: {{ .Values.team | default "platform" }}
{{- end }}
Then the consuming chart needs to provide team in its values.yaml:
# my-api/values.yaml
team: backend
replicaCount: 3
The library chart’s own values.yaml (if it had one) is ignored during rendering. Only the consuming chart’s values are used.
This means you should document what values your library expects. I add a comment block at the top of each template file:
{{/*
Required values:
- team (string): Team label value, defaults to "platform"
- partOf (string, optional): Part-of label, defaults to chart name
Optional values:
- fullnameOverride (string): Override the generated resource name
- nameOverride (string): Override the chart name in fullname generation
*/}}
A real-world library chart
Here’s a more complete library that handles common patterns I’ve needed across multiple charts.
templates/_deployment.tpl:
{{/*
Standard deployment container spec.
Usage: include "common.containerSpec" (dict "Values" .Values "Chart" .Chart)
*/}}
{{- define "common.containerSpec" -}}
name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }}
{{- if .Values.resources }}
resources:
{{- toYaml .Values.resources | nindent 2 }}
{{- end }}
{{- if .Values.livenessProbe }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 2 }}
{{- end }}
{{- if .Values.readinessProbe }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 2 }}
{{- end }}
{{- end }}
templates/_service.tpl:
{{/*
Standard service spec.
Usage: include "common.serviceSpec" (dict "Values" .Values "Release" .Release)
*/}}
{{- define "common.serviceSpec" -}}
type: {{ .Values.service.type | default "ClusterIP" }}
ports:
- port: {{ .Values.service.port | default 80 }}
targetPort: {{ .Values.service.targetPort | default .Values.service.port | default 80 }}
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ include "common.fullname" . }}
{{- end }}
Now a consuming chart’s deployment template becomes minimal:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "common.fullname" . }}
labels:
{{- include "common.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "common.fullname" . }}
template:
metadata:
labels:
{{- include "common.labels" . | nindent 8 }}
spec:
containers:
- {{- include "common.containerSpec" (dict "Values" .Values "Chart" .Chart) | nindent 10 }}
Publishing a library chart
Package and push like any other chart:
helm package ./common
helm push common-1.0.0.tgz oci://ghcr.io/myorg/charts
Consumers update by bumping the version constraint in their Chart.yaml:
dependencies:
- name: common
version: "1.x.x" # semver range, gets latest 1.x
repository: "oci://ghcr.io/myorg/charts"
I recommend using semver ranges (1.x.x or ~1.2.0) so consumers get patch fixes automatically but don’t get breaking changes without explicitly bumping.
When not to use library charts
Library charts add indirection. Someone reading your application chart has to look in two places to understand what a template renders. For a small org with 2-3 charts, the overhead might not be worth it. Just maintain the _helpers.tpl in each chart and accept the duplication.
The break-even point I’ve seen is around 5 charts. Below that, duplication is manageable. Above that, the library chart saves enough maintenance time to justify the added complexity.
Also, library charts can’t be installed or tested independently. You test them through the consuming charts. This means you need integration tests that install a real chart and verify the library’s templates render correctly. Unit testing with helm unittest or helm template helps, but it doesn’t catch everything.
Migrating existing charts to use a library
The migration path is straightforward but tedious:
- Create the library chart with your common templates
- In each consuming chart, add the library as a dependency
- Replace local
_helpers.tpldefinitions with calls to the library’s templates - Test each chart with
helm templateto verify the output is identical - Deploy to a staging environment and verify
- Update production
Step 4 is critical. A missing newline or different indentation from the library template can change your Kubernetes resources in subtle ways. Always diff the helm template output before and after migration.
I’ve done this migration twice now. The first time, I skipped the diff step and spent two hours debugging a service selector mismatch that changed zero lines of visible output (it was an extra trailing space). The second time, I diffed every chart. Took 10 minutes per chart, no surprises.
Trust the diff. Don’t trust your eyes.
Related Posts
Helm Hooks: Controlling When Things Happen
Your database migration runs before the app starts. How do you guarantee that in Helm?
Stop Hosting Your Helm Charts on GitHub Releases
Your charts shouldn't live in a GitHub repo's releases page. OCI registries solve chart distribution properly.
Building Helm Charts the Right Way
Every team eventually builds their own Helm chart. Here's how to do it right from the start.