Kubernetes Helm Chart Generator
Scaffold a Helm chart with Chart.yaml, values.yaml and templates that use nindent, required and the 63 character name truncation correctly. A chart is a directory, so the output is one document with a header per file, and every trap it avoids is explained beside it.
chart scaffold
updates as you type Common mistakes
These are the ones that fail silently. The config is accepted, nothing raises an error, and the consequence arrives later.
Putting environment values in values.yaml
That file is the chart's defaults and ships with it. Environment specifics belong in a separate file passed with -f.
Instead:Keep values.yaml generic and override per environment.
Templating without quoting values
A version like 1.10 renders as 1.1 and a string of digits becomes a number, which then fails validation in the manifest.
Instead:Use the quote function on anything that must stay a string.
Forgetting the chart version is separate from appVersion
Helm tracks changes by chart version. Bumping only appVersion means helm upgrade sees no change to the chart.
Instead:Bump version on every chart change.
What a Helm chart actually is, and where it goes wrong
A chart is a directory of Go templates plus a values file, and the templating is the part that surprises people. Go templating is not Jinja and it is not YAML-aware: it produces text, and the YAML parser only ever sees the result. So indentation is the chart author's problem, whitespace control is a language feature you have to use, and a chart can render perfectly for the values you tested and fail to parse for somebody else's.
version is the chart, appVersion is the application
Two version numbers with two lifecycles. version describes the packaging and is what helm package writes into the .tgz filename and what helm history shows. appVersion describes the code inside, and it is a string. Unquoted it is a YAML float, so 1.20 arrives as 1.2 with the trailing zero gone, and a chart that defaults its image tag from appVersion then points at a tag that does not exist.
apiVersion: v2
name: my-app
version: "0.4.2" # this chart packaging
appVersion: "1.20.0" # the code it deploys
# unquoted, these break differently:
# version: 0.4 -> the float 0.4
# appVersion: 1.20 -> the float 1.2, tag "1.2"
#
# apiVersion: v1 is the Helm 2 format. Helm 3 still
# installs it, and it keeps Helm 2 semantics: the
# dependencies below are read from requirements.yaml
# instead, and a dependencies: block here does nothing. indent and nindent, which is the bug you will actually hit
The template engine emits text at whatever column the text happens to be in. A block coming out of toYaml or include starts at column zero, so it has to be indented to fit where you put it. nindent is indent with a leading newline, and after a key on its own line that newline is the whole point. Get it backwards and the parse error points at the line after the mistake, which is why it takes so long to find.
resources:
{{- toYaml .Values.resources | nindent 4 }}
# nindent 4 renders:
# resources:
# requests:
# cpu: 100m
# indent 4 in the same place renders:
# resources: requests:
# cpu: 100m
# ^ still "valid" text, and the
# parser complains two lines down {{- and -}} delete whitespace, in one direction each
A control action on its own line leaves a blank line behind unless the dash trims it. The dash goes on the side you want removed, and it eats all whitespace including newlines. This matters most inside a list: an untrimmed action can leave a dangling indent that turns the next item into a continuation of the previous value, and only for the values that take that branch.
{{- if .Values.enabled }} <- trims before
key: value
{{- end }}
# without the dashes:
#
# <blank line where the if was>
# key: value
# <blank line where the end was>
#
# harmless at the top level, and inside a nested
# block the blank line is where a parse error comes
# from for one value of .Values.enabled and not
# the other. values.yaml is merged, except lists, which are replaced
Overrides are merged key by key, so a file that sets one key leaves every other default in place. Lists do not work that way. A list in an override replaces the whole list, element for element, and nothing warns you that the defaults went away. This is the most common surprise in the whole system, because merging is what the rest of the file taught you to expect.
# values.yaml
imagePullSecrets:
- name: registry-a
- name: registry-b
resources:
requests:
cpu: 100m
memory: 128Mi
# -f prod.yaml
imagePullSecrets:
- name: registry-c
resources:
requests:
cpu: 500m
# result:
# imagePullSecrets: [registry-c] a and b are gone
# resources.requests.cpu: 500m
# resources.requests.memory: 128Mi still there .Values, .Chart and .Release, and why $ exists
Three separate scopes hang off the root context. Inside a range or a with block the dot is rebound to the current item, so the root is no longer reachable through it. $ is the root, always, and it is how you get back to .Release.Name from inside a loop. The error when you forget is a nil pointer message that reads like a missing value rather than a scope problem.
.Values values.yaml after every override is merged
.Chart .Chart.Name, .Chart.Version, .Chart.AppVersion
.Release .Release.Name, .Release.Namespace,
.Release.Service, .Release.IsUpgrade
{{- range .Values.hosts }}
- host: {{ . }} <- the item
secret: {{ $.Release.Name }}-tls
{{- end }}
# .Release.Name inside the range:
# nil pointer evaluating interface {}.Release 63 characters, because names become DNS labels
A Kubernetes object name ends up in a DNS label, and a label stops at 63 characters. The fullname helper is release name plus chart name, so a long release name overflows it. Truncating alone is not enough: the cut can land exactly on a hyphen, and a label cannot start or end with one. That is what trimSuffix is doing there, and without it the chart installs for short release names and is rejected for long ones.
{{- define "my-app.fullname" -}}
{{- printf "%s-%s" .Release.Name $name
| trunc 63 | trimSuffix "-" -}}
{{- end -}}
# helm install a-very-long-release-name-here my-app
# trunc 63 alone -> "...-name-here-my-"
# the API server rejects it:
# must be a valid DNS-1123 subdomain Hooks are not part of the release
Helm creates a hook resource, waits for it, and then stops tracking it. It is not in the release manifest, so helm uninstall does not delete it, helm rollback does not know it ran, and the next upgrade collides with the object still sitting there. The delete policy annotation is the only thing that cleans it up. Weights order hooks within a phase, lowest first, and they are strings even though they look like numbers.
annotations:
"helm.sh/hook": pre-upgrade
"helm.sh/hook-weight": "-5" <- a string
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
# before-hook-creation delete the previous run first
# hook-succeeded delete it when it passes
# hook-failed delete it when it fails, which
# throws away the logs you wanted
# with no policy at all: the Job stays forever, and
# the next upgrade fails on "already exists". What each command actually checks
None of them is validation against your cluster. helm lint reads the chart structure and a few conventions, and says nothing about whether the rendered YAML is a legal object. helm template renders locally with no API server at all. helm install --dry-run does reach the API server and catches more, and still not everything. For schema validation against a specific Kubernetes version, render the chart and pipe it through kubeconform.
helm lint ./my-app
chart structure, Chart.yaml fields, icon,
values against values.schema.json.
Nothing about the rendered manifests.
helm template ./my-app
renders locally. Catches template errors and
YAML that does not parse. Knows nothing about
which apiVersions your cluster has.
helm install --dry-run
sends it to the API server: schema, admission,
webhooks. Does not run initContainers, does not
prove the image exists.
helm template ./my-app | kubeconform -strict \
-kubernetes-version 1.30.0