CoursesHelmChart structure

Chart structure

Chart.yaml, templates, values.

Intermediate12 min · lesson 3 of 12

A Helm chart is a package, and like a flat-pack furniture kit it ships with a label on the outside and pre-cut parts inside, laid out the same way every time. Helm (the package manager for Kubernetes, the system that runs your containers across a fleet of machines) reads that label to know what you are building and which version. Inside sit the assembly instructions and a sheet of default measurements you can change before you build. The command helm create hands you a kit already filled with a working example, so you learn the layout by reading it instead of memorizing a spec. The payoff is uniformity: every chart on Artifact Hub (the public hub where Helm charts are published and found) looks familiar once you can read one. For anyone operating or defending a cluster, that tree is also the exact list of what will reach the Kubernetes API server (the control-plane component that accepts and stores every object). Read the chart, and you know what you are about to run.

Scaffold a Chart and Read the Tree

The fastest way to learn the layout is to generate one and look at it. helm create produces a complete, conventional chart built around four things that carry the weight, plus one housekeeping file.

terminal
helm create web-api
find web-api | sort
output
Creating web-api
web-api
web-api/.helmignore
web-api/Chart.yaml
web-api/charts
web-api/templates
web-api/templates/NOTES.txt
web-api/templates/_helpers.tpl
web-api/templates/deployment.yaml
web-api/templates/hpa.yaml
web-api/templates/httproute.yaml
web-api/templates/ingress.yaml
web-api/templates/service.yaml
web-api/templates/serviceaccount.yaml
web-api/templates/tests
web-api/templates/tests/test-connection.yaml
web-api/values.yaml

Four pieces matter. Chart.yaml is the label. values.yaml holds the default settings. templates/ holds the Kubernetes manifests (YAML descriptions of cluster objects) written as fill-in-the-blank templates: deployment.yaml for the running pods, service.yaml for a stable in-cluster address, hpa.yaml for a HorizontalPodAutoscaler that scales the pod count under load, and ingress.yaml plus httproute.yaml for routing outside traffic in. charts/ is where subcharts (other charts this one depends on) get unpacked. The fifth entry, .helmignore, lists glob patterns (wildcard filename rules such as *.bak) that Helm skips when it loads and packages the chart.

One rule is worth burning into memory. Helm renders every file under templates/ and sends the result to the cluster, with exactly two hard-coded exceptions: files whose names start with an underscore, and NOTES.txt. Everything else, subdirectories included, becomes a Kubernetes object. The underscore file here, _helpers.tpl, holds reusable template snippets that other files pull in; NOTES.txt is the short message printed after an install. You can render the whole chart on your laptop with no cluster attached, using helm template, and that is where every audit should start. The blast radius of a chart is whatever helm template prints, no more and no less.

Chart.yaml, the Label on the Box

Chart.yaml is the label: what this is, which version, and what application it builds. Here is a filled-in one.

web-api/Chart.yaml
apiVersion: v2 # v2 = the modern chart format (Helm 3 and Helm 4)
name: web-api
description: A sample web API service
type: application # or "library" for a templates-only chart
version: 0.1.0 # the CHART's own version (Semantic Versioning)
appVersion: "1.16.0" # the app inside; quote it, it is a string
keywords:
- web
- api
home: https://example.com/web-api
sources:
- https://github.com/example/web-api
maintainers:
- name: platform-team
# dependencies: # subcharts; see the Subcharts and dependencies lesson
# - name: redis
# version: "19.x"
# repository: https://charts.example.com

apiVersion: v2 marks the modern chart format that Helm 3 and Helm 4 read; v1 is the old Helm 2 format you should never write for new work. The field that trips people up is that there are two versions in this file, and they run on separate clocks. version is the chart's own number, shaped as Semantic Versioning (MAJOR.MINOR.PATCH, so 0.1.0); bump it whenever you change anything in the chart. appVersion is the version of the application inside; it sets the default container image tag and shows up in helm list, and shipping a new build of your app does not force a chart bump unless the templates changed too. For a defender that second field is the fast answer to "which build is running," the first thing you need when a CVE (Common Vulnerabilities and Exposures, a public catalog of known security flaws) lands against your image. type is application for something you deploy, or library for a chart that only exports shared templates and cannot be installed on its own. dependencies declares subcharts, covered in its own lesson.

templates/ and values.yaml, the Blanks and Their Defaults

templates/ and values.yaml are the working pair: the assembly instructions and the sheet of default measurements. The instructions are Go templates (text files with {{ }} placeholders, from the Go programming language) that read from a context, most often .Values (everything in values.yaml) and .Chart (fields from Chart.yaml). values.yaml supplies the defaults those placeholders resolve to, and it doubles as the chart's written contract: it is the first file a consumer opens to see which knobs exist and what they default to.

web-api/values.yaml
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
service:
type: ClusterIP
port: 80
resources: {} # left empty so consumers opt in to CPU/memory limits
web-api/templates/deployment.yaml
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
...
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}

Read that image line closely. It builds the tag from .Values.image.tag, and when the tag is left empty it falls back to .Chart.AppVersion. values.yaml is also where the security-relevant settings live: the image and its pinned tag, resources limits so one pod cannot starve a node, securityContext, and whether a service account's credentials get mounted. Reading it is how you audit a chart before you trust it. You do not override these by editing the file; consumers layer their own values on top at install time, which the Values and overrides lesson covers. The files prefixed with an underscore, like _helpers.tpl, are meant to be pulled into other templates rather than rendered on their own, and NOTES.txt is a template whose output is printed once the install finishes.

Render Before You Apply

Rendering turns the templates and the values into the plain Kubernetes YAML that would actually be applied. No cluster is touched, so it is safe to run against anything, including a chart you downloaded and do not yet trust.

terminal
helm template web-api --show-only templates/deployment.yaml
output
---
# Source: web-api/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: release-name-web-api
labels:
helm.sh/chart: web-api-0.1.0
app.kubernetes.io/name: web-api
app.kubernetes.io/instance: release-name
app.kubernetes.io/version: "1.16.0"
app.kubernetes.io/managed-by: Helm
spec:
replicas: 1
...
containers:
- name: web-api
image: "nginx:1.16.0"
imagePullPolicy: IfNotPresent

The empty tag became nginx:1.16.0, pulled straight from appVersion, exactly as that template line promised. Notice the labels Helm stamps on everything it renders: app.kubernetes.io/managed-by: Helm and helm.sh/chart: web-api-0.1.0. During an incident those labels let you trace any live object back to the chart and release that created it, instead of guessing who owns a stray Deployment. Rendering first also means you review the real YAML, with every value resolved, before the API server ever sees it.

Keep Junk Out of templates/

The render rule has a sharp edge. Because templates/ renders everything, a file that does not belong there gets treated as a manifest anyway. Watch what a harmless-looking backup does.

terminal
# a well-meaning backup, left inside templates/
cp web-api/templates/deployment.yaml web-api/templates/deployment-old.yaml
# and a .bak copy, which .helmignore *does* list
cp web-api/templates/deployment.yaml web-api/templates/deployment.yaml.bak
# how many Deployments does the chart render now?
helm template web-api | grep -c 'kind: Deployment'
output
2

Two Deployments, not one. The .bak copy was dropped because .helmignore lists *.bak, but deployment-old.yaml matches nothing in the default ignore list, so Helm rendered it into a second, real Deployment. On a live cluster that is a duplicate object you never meant to create. Swap that backup for a stray README.md and you get the loud version instead: Error: YAML parse error on web-api/templates/README.md: error unmarshaling JSON: ... cannot unmarshal string into Go value of type util.SimpleHead. Either way, the fix is the same: keep the directory clean and render before you ship.

Everything in templates/ is a manifest, including your mistakes
Helm renders every file under templates/ except names starting with an underscore and NOTES.txt, subdirectories included. .helmignore only saves you for patterns it actually lists (*.bak, *.orig, *.swp, .git/); a plain README.md or deployment-old.yaml matches nothing and sails straight through. That is a supply-chain seam: anyone who can land a file in templates/ (a merged pull request, a compromised dependency, a shared repo) can get an arbitrary object applied to your cluster on the next install. Keep non-manifest files out of templates/, prefix helpers with an underscore, and read helm template output before every apply.
How a chart becomes objects in your cluster
1Chart directory
Chart.yaml, values.yaml, templates/, charts/
2Helm loads it
.helmignore patterns dropped here
3Render templates/
.Values and .Chart fill the blanks
4Skip _*.tpl and NOTES.txt
partials and the post-install note
5Kubernetes manifests
one object per rendered file
6API server applies them
labeled managed-by: Helm
Quick check
01You copy deployment.yaml to deployment-old.yaml inside templates/ as a quick backup, then run helm template web-api | grep -c 'kind: Deployment'. What number prints, and why?
Incorrect — The ignore list works on patterns such as *.bak and *.orig, which is why the .bak copy disappeared and the plain name did not.
Incorrect — Nothing cancels out at render time. Both files produce a manifest, which is precisely the duplicate object you never meant to create.
Correct — Only two names are ever skipped in that directory: anything starting with an underscore, and NOTES.txt. Your copy is neither, so it becomes a real second object.
Incorrect — The kind does come from inside the file, but Helm never deduplicates by kind. It renders each file it loads, so you get two.
02The scaffolded chart leaves tag: "" in values.yaml, and the Deployment reads image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}". You edit Chart.yaml to appVersion: "1.17.0", leave version: 0.1.0 untouched, and rerun helm template. What image line renders?
Correct — The fallback fires only while image.tag is empty, so moving appVersion moves the shipped tag with it.
Incorrect — 0.1.0 is the chart's number and it surfaces in the helm.sh/chart label, but the template asks for AppVersion instead.
Incorrect — Nothing is frozen at scaffold time. Every render reads Chart.yaml as it stands right now, so your edit takes effect immediately.
Incorrect — Helm contacts no registry while rendering. The tag is resolved on your laptop from the chart's own fields, and there is no implicit latest.
03A teammate switches type: application to type: library in Chart.yaml so other charts can pull in the helpers, then runs helm install mylib ./mylib. What should you tell them to expect?
Incorrect — Helm acts on that field rather than merely describing it, so what you put there decides whether an install is possible at all.
Incorrect — No hollow release appears in helm list. Helm stops before anything is created, so there is nothing to inspect afterwards.
Incorrect — Helm never edits your Chart.yaml. The field stays as committed, and so does the refusal, on every attempt.
Correct — Those templates exist to be imported by another chart, so there is no object for the API server to receive.

Before every apply, run helm template and read the output like a diff. The file you forgot to delete is the object you did not mean to create, and the render is the only place you will catch it before the API server does.

Try this

Run helm create web-api 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: everything in templates/ is a manifest, including your mistakes. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related