Library charts & reuse
Shared templates across charts.
A busy kitchen keeps one binder of prep recipes on a shelf. Nobody cooks the binder, and nobody serves it. Every station pulls recipes out of it, and the day the head chef writes a food-safety rule into one recipe (cook the chicken to 74 degrees Celsius), every dish that follows that recipe inherits the fix at once. A Helm library chart is that binder. Helm is the package manager for Kubernetes (often shortened to k8s), the system that schedules and runs your containers. A container is a self-contained box that holds an app and everything it needs to run. A chart is Helm's unit of packaging: a folder of templated YAML (a plain-text format for configuration files). A library chart ships no runnable output of its own. No Deployment (the Kubernetes object that keeps a set of identical containers running), no Service (the object that hands those containers one stable network address), nothing that ever lands in a cluster (the pool of machines Kubernetes runs everything on).
What it holds instead is reusable named templates. Those are the define blocks you write in helper files: a chunk of YAML given a name, ready to be called by other charts. Twenty microservice charts (each one packaging a small, independently deployed piece of a bigger app) that all want the same labels, the same health probes (the periodic checks Kubernetes runs to confirm a container is alive and ready for traffic), and the same hardened security settings can call one shared template instead of pasting the same forty lines into twenty places. Write it once. Fix it once. Ship the fix everywhere by bumping a version number.
A chart that renders nothing
You turn an ordinary chart into a library with one line in Chart.yaml (the file that describes the chart): type: library. Application charts default to type: application, and those are the ones Helm installs. A library is different on purpose. Helm renders none of its templates on its own, and running helm install against it fails by design. That refusal is a feature. A shared toolbox of templates should never be able to drop something into a cluster by accident; the only way its YAML reaches a cluster is when a real application chart chooses to call it.
apiVersion: v2name: commontype: library # not installable; renders nothing on its ownversion: 1.0.0
All the logic lives inside define blocks, kept in files whose names start with an underscore. Helm treats underscore-prefixed files as partials: helper files that never render into output on their own. That naming convention is what stops a stray helper from turning into a stray manifest (a manifest is a finished YAML document Kubernetes reads to create an object). Running helm create scaffolds an application chart, so you build a library either by hand or by scaffolding one and switching its type.
{{- define "common.deployment" -}}apiVersion: apps/v1kind: Deploymentmetadata:name: {{ .Release.Name }}-{{ .Chart.Name }}labels:app.kubernetes.io/name: {{ .Chart.Name }}app.kubernetes.io/managed-by: {{ .Release.Service }}spec:replicas: {{ .Values.replicaCount | default 1 }}selector:matchLabels:app.kubernetes.io/name: {{ .Chart.Name }}template:metadata:labels:app.kubernetes.io/name: {{ .Chart.Name }}spec:securityContext:runAsNonRoot: trueseccompProfile:type: RuntimeDefaultcontainers:- name: {{ .Chart.Name }}image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"securityContext:allowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"]ports:- containerPort: {{ .Values.service.port }}{{- end -}}
Notice the helper reads .Values, .Release, and .Chart. It owns none of them. It borrows them from whichever chart calls it, which is the whole trick. On its own this file produces nothing, because a define block only registers a name. Something else has to invoke that name before any YAML appears.
Why it refuses to install
helm install demo ./common
Error: library charts are not installable
That error is the guardrail working. If you see it while trying to install what you thought was an app chart, you left type: library in a Chart.yaml by mistake, or you pointed helm install at the wrong directory. For a security reviewer it is a reassuring property: a chart marked library cannot, by itself, put a single object into your cluster.
Wiring it into an application chart
A consumer pulls the library in the same way it pulls any dependency (the same mechanics as the subcharts lesson). List it under dependencies in Chart.yaml, point repository at a file:// path on disk or a hosted Helm repository, then run helm dependency update to fetch it into the chart's charts/ folder.
This looks like a subchart, and the plumbing is identical, but the behavior is the opposite. A subchart is a chart nested inside another one. A normal subchart renders its own manifests the moment you install the parent; you get its Deployments whether you asked for them or not. A library renders nothing until your template explicitly calls one of its helpers. A subchart pushes resources at you. A library waits to be asked.
apiVersion: v2name: webversion: 0.1.0dependencies:- name: commonversion: "1.0.0"repository: "file://../common" # or a hosted repo, or an oci:// reference
replicaCount: 1image:repository: nginxtag: "1.27"service:port: 8080
helm dependency update ./web
Hang tight while we grab the latest from your chart repositories...Update Complete. ⎈Happy Helming!⎈Saving 1 chartsDeleting outdated charts
ls web/charts/
common-1.0.0.tgz
That tarball (a .tgz compressed archive) is a snapshot of the library, copied into your consumer chart. Hold that thought, because the snapshot is where local iteration usually goes wrong. With the dependency in place, calling the shared template takes one line.
{{ include "common.deployment" . }}
One line stands in for the whole Deployment. include runs a named template and hands back its text, which you can pipe into indentation helpers when you slot a snippet into a bigger file. (Its cousin template writes output directly into the surrounding text and cannot be piped, so include is the one to reach for.) The . at the end is the part that matters. It passes the consumer's context into the helper, which is why the helper's .Chart.Name resolves to web, not common, and why the same shared template produces a differently named resource in every chart that calls it.
Sometimes you need to feed the helper more than the plain consumer context. The second argument to include can be any value you like, so you can hand it a small dictionary, for example include "common.deployment" (dict "Values" .Values "extra" "sidecar"), and the helper reads whatever you packed in. Passing . is the common case. Passing a custom dict is how you parameterize a shared template beyond straight values.
Rendering the whole resource
helm template ./web
---# Source: web/templates/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata:name: release-name-weblabels:app.kubernetes.io/name: webapp.kubernetes.io/managed-by: Helmspec:replicas: 1selector:matchLabels:app.kubernetes.io/name: webtemplate:metadata:labels:app.kubernetes.io/name: webspec:securityContext:runAsNonRoot: trueseccompProfile:type: RuntimeDefaultcontainers:- name: webimage: "nginx:1.27"securityContext:allowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"]ports:- containerPort: 8080
Render it before you ship it. helm template expands everything locally without touching a cluster, so you can read exactly what would be applied. Look at what the shared helper baked in: runAsNonRoot, a read-only root filesystem (the container cannot write to its own disk, so an attacker who lands code inside it has nowhere to drop a payload), every Linux capability dropped, and privilege escalation switched off. The platform team wrote that hardening once. The day a new control is needed, say a stricter seccomp profile (a Linux kernel feature that limits which system calls a container may make; the kernel is the core of the operating system that talks to the hardware), they add it to the library, cut a new version, and every consumer that bumps its dependency inherits it. Not twenty pull requests. One. That single point of change is also the single place a defender audits to confirm the baseline is actually in force across the whole fleet.
Keep names from colliding
Give every helper a namespace: common.deployment, never a bare deployment. Named templates share one flat global space across the parent chart and all of its subcharts, so two helpers both called deployment would clobber each other, and the last one loaded would silently win. Prefixing every name with the library's own name keeps them apart. This is how well-worn libraries like Bitnami's common chart are built: dozens of small, namespaced helpers (image references, label sets, name builders) that many charts import instead of reinventing them.
helm template ./web prints a Deployment named release-name-web. The helper that built it lives in the common chart. What decided that name?dependencies list, then run helm dependency update ./web. Both land in web/charts/ as tarballs. What changes once you install the parent?repository: "file://../common". You tighten securityContext in common/templates/_deployment.yaml, run helm template ./web, and the output comes back unchanged. What explains it?Before you bump a library version that hundreds of charts depend on, render at least one real consumer with helm template and diff the output against the previous version. A one-character change in a shared helper reaches every chart that calls it. That reach is the power of a library chart, and it is also why a careless edit travels exactly as far.
Try this
Run helm install demo ./common on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: your charts/ tarball goes stale. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.