Building production-grade chart documentation: the README.md that becomes a contract between teams, NOTES.txt guiding users after installation, clear values documentation, automatic generation with helm-docs, and the CHANGELOG.md that maintains chart users' trust.

After episode 14, where we covered how to test charts systematically — from helm test, unit tests with helm-unittest, manifest validation with kubeconform, to linting and CI testing integration with chart-testing — in this episode we shift from the realm of "checked by machines" to the realm of "read by humans": chart documentation.
This topic is often treated as an afterthought, yet it's actually decisive. Imagine you're a platform engineer tasked with consuming a chart from another team. Without documentation, you have to read hundreds of lines of values.yaml, guess the meaning of every key, and hope helm install doesn't blow up. Every chart you publish — to Artifact Hub, to an internal repo, or to your own team — is a product that others must be able to use without asking its creator. README.md is the front door of that product, NOTES.txt is the assistant standing beside users after installation completes, and CHANGELOG.md is the transparency record that makes users brave enough to upgrade.
The problem is that manual documentation goes stale quickly. Every time you add a parameter to values.yaml, the configuration table in the README is immediately out of sync. In this episode we solve that problem at its root: dissecting the contents of a complete README.md, writing NOTES.txt that genuinely helps (not just a template), documenting values with discipline, then automating everything with helm-docs so documentation always stays in line with the code. We close with CHANGELOG.md as a tool for communicating version changes.
README.md is the first document anyone reads before deciding to use your chart. It's the contract between creator and user: users are entitled to know what they're installing, what the prerequisites are, and how to use it. A good README answers six questions in a logical order.
First, the chart description at the very top. One or two sentences answering: what application is this, and what is it for? Don't copy the description from Chart.yaml verbatim — in the README you can add context, for example a brief architecture or relationships between components. Second, the prerequisites. This is a list of honesty: which Kubernetes version, which Helm version, whether a particular storage class is needed, whether an Ingress Controller is needed, whether cert-manager is needed. Skipping this section is the most common source of support tickets — users install the chart with an unsupported version, then report "the chart is broken."
Third, installation instructions. From adding the repository to a copy-paste-ready helm install command, complete with --namespace and --create-namespace options. Fourth, the parameter configuration table. This is the part people search for most: every key in values.yaml, its meaning, its data type, and its default. Fifth, usage examples — for example, how to enable Ingress or set resource limits. Sixth, upgrading notes and uninstallation. The upgrade section explains what changed between versions and migration steps; the uninstall section is just helm uninstall <release>, but it should mention whether persistent data is also deleted.
Hold onto one golden rule: a stale README is worse than no README at all. Outdated documentation misleads; absent documentation is at least honest. That's why we won't write the parameter table manually — we'll generate it automatically, which we cover in the helm-docs section.
When helm install completes, Helm runs the templates/NOTES.txt file and prints the result to the terminal. This isn't decoration: it's the first stage of the user's interaction with their release. Think back to the experience of installing a chart that ended with confusing text like "Your release has been deployed" and nothing after it. Users immediately ask: "And then? How do I access it?"
A good NOTES.txt answers that question proactively. The information that must be present: how to get the application's access URL, credentials to retrieve (for example, a generated admin password), and other important information like data location or security notes. What makes NOTES.txt powerful is that it supports full templating — it's a regular Go template, so it can read .Values, .Release, even the rendered output of other resources.
A real example, access notes that depend on whether Ingress is enabled:
1. Get the application URL with the following command:
{{- if .Values.ingress.enabled }}
echo "Access the app at: https://{{ .Values.ingress.host }}"
{{- else }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "myapp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
echo "Access the app at: http://127.0.0.1:8080"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80
{{- end }}
2. The default password is stored in the Secret "{{ include "myapp.fullname" . }}-admin":
kubectl get secret --namespace {{ .Release.Namespace }} {{ include "myapp.fullname" . }}-admin \
-o jsonpath="{.data.admin-password}" | base64 -dNotice how {{- if .Values.ingress.enabled }} and {{- end }} make the output differ depending on the configuration. If Ingress is enabled, users are pointed to the hostname that was already set; if not, they're given a copy-paste-ready port-forward command. This is the same pattern as conditional rendering in ordinary resource templates (episode 10) — only the output is for humans, not the API server.
Tip
Because NOTES.txt is printed every time helm install and helm upgrade finish, don't put in information that's only relevant the first time — for example, "default password" will keep being printed on upgrade, even though the password has already been changed. For sensitive information, point users to how to retrieve it themselves (like kubectl get secret) rather than printing it directly.
One more thing: NOTES.txt — the file templates/NOTES.txt with the .txt extension — is rendered as a template. But templates/NOTES.txt does use a special name; don't name it notes.md or notes.yaml, because Helm recognizes this pattern specifically.
In episode 7 we learned that values.yaml is the chart's main interface. In this episode we take its documentation seriously, because values documentation is API documentation — and an API without documentation is an invitation to misuse.
First discipline: inline comments in values.yaml. Every non-trivial key deserves a comment explaining what it does, not what its data type is (that's already obvious from its value). Compare these two styles:
replicaCount: 3
image:
repository: nginx
tag: "1.27.3"
resources:
requests:
memory: 256Mi# Number of replicas running for the main service.
# Increase at peak load; decrease off-peak hours.
replicaCount: 3
image:
# Image repository; use an immutable tag in production.
repository: nginx
# Do not use the "latest" tag - hard to roll back.
tag: "1.27.3"
resources:
# Requests are important for pod scheduling and HPA.
requests:
memory: 256MiThe difference above isn't cosmetic. The first comment explains why the default value was chosen — that prevents others from changing it without context. The comment about the latest tag prevents one of the most common production mistakes. The principle: comments answer "why," not "what."
Second discipline: explain the default values. Chart users read the parameter table to decide whether they need to change anything. If you just write replicaCount | int | 3, users don't know whether 3 is a recommended number or just an arbitrary default. Add a context sentence: "3 replicas suit HA with a minimum of 1; start from 2 in small environments."
Third discipline: usage examples. For complex parameters — for example, ingress.annotations or extraEnv — include example snippets in the README or comments. Concrete examples are far easier to understand than abstract descriptions. And the last discipline: consistency in naming and structure, so users already familiar with other charts can guess key locations accurately.
This is where the "stale documentation" problem is solved. helm-docs (github.com/norwoodj/helm-docs) is a tool that reads your values.yaml and generates the parameter table section of the README automatically. It works through a template called README.md.gotmpl — a Go template where you write the static parts of the README, then insert placeholders for the parts generated from values.
How it works is simple: helm-docs parses values.yaml, extracts every key along with its type and default, then renders placeholders like {{ template "chart.valuesTable" . }} into a tidy Markdown table. Because the table is generated from the same source Helm uses at runtime, it's no longer possible to forget documenting a key — whenever values.yaml changes, the table changes with it.
An example of README.md.gotmpl and its rendered result:
::: code-group
# myapp
Production-grade deployment for the `myapp` application on Kubernetes.
## Prerequisites
- Kubernetes >= 1.26
- Helm >= 3.12
- Ingress Controller (if enabling `ingress.enabled`)
## Installation
```shell
helm repo add myorg https://charts.myorg.example.com
helm install myapp myorg/myapp --namespace myapp --create-namespace
```
## Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
{{ template "chart.valuesTable" . }}
## Uninstall
```shell
helm uninstall myapp --namespace myapp
```## Parameters
| Parameter | Description | Default |
|-----------|-------------|---------|
| replicaCount | Number of main service replicas | 3 |
| image.repository | Application image repository | nginx |
| image.tag | Image tag (avoid latest) | 1.27.3 |
| image.pullPolicy | Image pull policy | IfNotPresent |
| service.type | Kubernetes Service type | ClusterIP |
| service.port | Port exposed by the Service | 80 |
| ingress.enabled | Enable Ingress resource | false |
| ingress.host | Hostname for Ingress | myapp.example.com |
| autoscaling.enabled | Enable HPA | false |
| autoscaling.minReplicas | HPA minimum replicas | 1 |
| autoscaling.maxReplicas | HPA maximum replicas | 5 |
| resources.limits.memory | Pod maximum memory limit | 512Mi |
| resources.requests.cpu | CPU request per pod | 100m |
:::
Note that you **don't write the table yourself** — you only write the `{{ template "chart.valuesTable" . }}` placeholder. To produce the final README:
```bash icon="iHelm" title="Running helm-docs"
helm-docs --chart-search-root ./chartsThe command above reads README.md.gotmpl and values.yaml in every chart under ./charts, then writes the final README.md. CI integration works the same way — just run helm-docs, then if there are changes (checked with git diff --exit-code), fail the pipeline because it means documentation hasn't been regenerated. That way, documentation is forced to be in sync before code can be merged.
Important
helm-docs only generates the parameter table, not the entire README. The description, prerequisites, and instruction sections still have to be written manually in README.md.gotmpl. That's actually its advantage: narrative text is written once, while the table that easily goes stale is continuously synced. One template file becomes the single source of truth.
helm-docs also supports configuration via a helm-docs.yaml file to manage options like sortValuesOrder, templatesLocation, or a custom output file. And because the table is generated from values.yaml, the inline comment discipline we discussed earlier becomes doubly important: comments in values.yaml now also appear as the "Description" column in the README table. Values documentation and README become one flow, not two separate things.
The last documentation piece is actually the most often ignored: CHANGELOG.md. It's a record of every chart version's journey — what changed, what new features came, what bugs were fixed, and most importantly: what changes break things (breaking changes). Why is this crucial? Because chart users never upgrade lightly. Every version bump is a risk; a CHANGELOG is how you lower that risk with transparency.
A good CHANGELOG structure follows the "Keep a Changelog" pattern: the newest version at the top, each version with a date, and changes grouped by category — Added, Changed, Removed, Fixed. What distinguishes a production-grade chart CHANGELOG from mere notes is a migration section for every version containing breaking changes. A real example:
# Changelog
All notable changes to this chart will be documented in this file.
Format follows "Keep a Changelog", versions follow Semantic Versioning.
## [2.0.0] - 2026-08-02
### Added
- Horizontal Pod Autoscaler support (`autoscaling.enabled`).
- Per-pod `securityContext` configuration for podSecurityContext and containers.
### Changed
- **Breaking**: `service.annotations` structure moved to `service.annotationsPod`.
Migration: move pod annotations from `service.annotations` to `service.annotationsPod`.
- Default `image.tag` changed from `1.25` to `1.27.3`.
### Removed
- The `replicas` parameter (replaced by `replicaCount`, deprecated since 1.4).
## [1.4.0] - 2026-05-10
### Added
- `nodeSelector` and `tolerations` parameters.
- Extra `app.kubernetes.io/version` label on the Deployment.
### Fixed
- Wrong volume mount on the init container when `extraVolumes` is used.Notice the [2.0.0] entry: this is a MAJOR version (up from 1.x to 2.0.0), and every breaking change is labeled **Breaking** with migration instructions. This is what makes users brave enough to upgrade: they know exactly what must be adjusted, without reading the entire template diff. Conversely, a CHANGELOG that's never updated makes users postpone upgrades — and postponing upgrades is the same as accumulating security and feature debt.
If you're already using semantic-release (as we discussed in the CI context), the CHANGELOG can be generated from conventional commits with tools like standard-version or the semantic-release/changelog plugin. But whatever the tool, the principle remains: every release must have an honest entry, especially about breaking changes.
Warning
One common mistake: writing a CHANGELOG but forgetting the MAJOR version bump when there are breaking changes. A CHANGELOG stating "this breaks things" while the version only bumps PATCH will explode in the hands of users using semver constraints like ^1.2.3 — they'll automatically receive a version that breaks their application. Understand the semver rules fully; we dissect them more deeply in episode 16.
In this episode 15 we built the foundation for production-grade chart documentation. We started with README.md as the contract between teams — description, prerequisites, installation instructions, parameter table, examples, upgrade notes, and uninstallation — then NOTES.txt as the post-installation assistant that supports templating and conditional rendering so access instructions are always relevant. We disciplined values documentation with comments explaining "why," freed ourselves from stale documentation with helm-docs, which generates the parameter table directly from values.yaml, and closed with CHANGELOG.md, which lowers upgrade risk through transparency and migration guidance.
The core takeaways:
Now your chart not only works, it's also usable by others with confidence. In the next episode, episode 16, we dissect chart packaging and versioning: proper Semantic Versioning, the difference between version vs appVersion, helm package into a .tgz archive, signing and provenance verification with GPG, and the Chart.lock that guarantees reproducibility. Keep your spirits up!