Your First Chart: Packaging Bookstore.Api with helm create, Chart.yaml, templates/ and values.yaml
A Helm chart consists of Chart.yaml (the chart's version and the application's appVersion), values.yaml (the chart's interface) and the Go templates in templates/. We trim the helm create skeleton, turn Bookstore.Api's Deployment, Service and ServiceAccount into templates, and look with real output at the built-in objects, the difference between helm template and --dry-run=server, and the pitfalls of fixed resource names and an image tag that ignores appVersion.
Version note: The examples in this post were run with Helm 4.3.2, kind 0.33 with Kubernetes 1.37, kubectl 1.37 and .NET 10.
Guess First
In the chart's Chart.yaml you raised appVersion from 1.0.0 to 1.1.0 and upgraded. The APP VERSION column in helm list says 1.1.0. But a week ago, while debugging a problem, you set the image tag by hand with --set image.tag=1.0.0. Which image is running in the pod, and who tells you? The answer is in the pitfalls section at the end of this post, with real output. On the way there is also a surprise about how Helm remembers values.
A Skeleton with helm create
In the first post of the series we installed a ready-made chart. In this post we write our own chart for the API of Bookstore, the online bookstore used as the shared example across this site's series. The starting point is the manifests from the Kubernetes series: Deployment, Service, ServiceAccount, probes and a securityContext that fits the restricted profile of the Pod Security Standards. The chart lives in the application's repo under helm/charts/bookstore. helm create generates a working sample skeleton:
mkdir charts
cd charts
helm create bookstore
Creating bookstore
bookstore/.helmignore
bookstore/Chart.yaml
bookstore/templates/NOTES.txt
bookstore/templates/_helpers.tpl
bookstore/templates/deployment.yaml
bookstore/templates/hpa.yaml
bookstore/templates/httproute.yaml
bookstore/templates/ingress.yaml
bookstore/templates/service.yaml
bookstore/templates/serviceaccount.yaml
bookstore/templates/tests/test-connection.yaml
bookstore/values.yaml
This skeleton was written to install nginx and exposes everything through values: ingress, HTTPRoute, HPA, a test pod, tolerations, affinity. For Bookstore.Api we need three resources for now. We delete the rest; we will add things deliberately when we need them, and the test pod comes back in the sixth post:
cd bookstore
rm templates/hpa.yaml templates/ingress.yaml templates/httproute.yaml
rm -r templates/tests
_helpers.tpl and .helmignore stay. We use the name and label functions in the helper file as they are in this post and rewrite them for our own needs in the third post. .helmignore lists files that are left out of the package when helm package runs; it works like .gitignore.
Chart.yaml and values.yaml
apiVersion: v2
name: bookstore
description: Bookstore API (ASP.NET Core 10) on Kubernetes
type: application
# The chart's version: bumped when templates or values change
version: 0.1.0
# The application's version: used when the image tag is empty
appVersion: "1.0.0"
apiVersion: v2 is the chart format introduced with Helm 3 and still valid in Helm 4. Charts from the Helm 2 era use v1; we look at reading them in the seventh post. The distinction that really matters is between version and appVersion. version is the chart's own version and must be semver; it goes up when you change a template, a default in values.yaml or a dependency. appVersion is the version of the application the chart installs, is free text and follows Bookstore.Api's release number. The two move independently: you can fix the chart without changing the application, or package a new application version without touching the chart. The quotes are not an accident: if you write appVersion: 1.10, YAML reads it as a number and the version becomes 1.1; helm lint catches it with appVersion should be of type string but it's of type float64.
replicaCount: 1
image:
repository: helmseries-bookstore-api
# Empty tag: the chart uses appVersion from Chart.yaml.
tag: ""
pullPolicy: IfNotPresent
environment: Development
database:
# InMemory needs no database server; SqlServer comes in post 04.
provider: InMemory
service:
type: ClusterIP
port: 8080
probes:
livePath: /health/live
readyPath: /health/ready
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
values.yaml is the chart's interface. Every key you put here becomes a contract that everyone using the chart will depend on; renaming it is a breaking change. So we only expose settings that really change from environment to environment: replica count, image, environment name, database provider, service type and resources. The container port (8080) and the securityContext are not exposed, because the Bookstore image defines them itself and there is no reason for them to differ between environments. In the lab the images were loaded into kind with a helmseries- prefix; in a real setup this would be a registry address such as ghcr.io/<org>/bookstore-api. database.provider: InMemory is a setting we added to Bookstore.Api for this series: it starts the API without a database server, with EF Core's in-memory provider and the seed data. Bookstore's real database is SQL Server; we switch to it in the fourth and fifth posts. The Kubernetes series used PostgreSQL; in this series we went back to Bookstore's default.
Templates: Deployment, Service, ServiceAccount
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "bookstore.fullname" . }}-api
labels:
{{- include "bookstore.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "bookstore.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: api
template:
metadata:
labels:
{{- include "bookstore.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: api
spec:
serviceAccountName: {{ include "bookstore.fullname" . }}-api
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8080
env:
- name: ASPNETCORE_ENVIRONMENT
value: {{ .Values.environment }}
- name: Database__Provider
value: {{ .Values.database.provider }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
startupProbe:
httpGet:
path: {{ .Values.probes.livePath }}
port: http
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet:
path: {{ .Values.probes.livePath }}
port: http
periodSeconds: 10
readinessProbe:
httpGet:
path: {{ .Values.probes.readyPath }}
port: http
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir:
sizeLimit: 64Mi
There are two differences from the Deployment in the Kubernetes series. Everything that varies comes from values through {{ .Values... }}, and the names are not fixed: {{ include "bookstore.fullname" . }}-api. The {{ .Values.image.tag | default .Chart.AppVersion }} on the image line is a pipeline: if the tag is empty, appVersion is used. toYaml ... | nindent 12 turns the resources map from values into YAML and indents it by 12 spaces; the details of the template language are the topic of the next post. We added app.kubernetes.io/component: api to the labels because in the third post a second component (bookstore-web) joins the same chart, and the selectors of the two Deployments must not overlap. Selectors cannot be changed once created; we saw why in the fourth post of the Kubernetes series.
apiVersion: v1
kind: Service
metadata:
name: {{ include "bookstore.fullname" . }}-api
labels:
{{- include "bookstore.labels" . | nindent 4 }}
app.kubernetes.io/component: api
spec:
type: {{ .Values.service.type }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
selector:
{{- include "bookstore.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: api
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "bookstore.fullname" . }}-api
labels:
{{- include "bookstore.labels" . | nindent 4 }}
app.kubernetes.io/component: api
automountServiceAccountToken: false
To see where the name comes from, let us look at the helper that helm create generated:
{{- define "bookstore.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 }}
If the release name contains the chart name (bookstore, bookstore-dev), the name is the release name itself; if not (preview), it becomes preview-bookstore. The 63-character limit comes from Kubernetes' DNS-compatible naming rule. The real job of this function is to let the same chart be installed twice into the same namespace; we will see why that matters in the pitfall section below.
Built-in Objects: .Values, .Release, .Chart, .Capabilities
Besides .Values, Helm fills in a few more objects for the templates. .Release carries the release's name, namespace, revision number and whether this is an install or an upgrade (.Release.IsInstall, .Release.IsUpgrade). .Chart is the content of Chart.yaml; its field names start with a capital letter (.Chart.AppVersion). .Capabilities tells you the cluster's Kubernetes version and the APIs it supports; for example, with .Capabilities.APIVersions.Has "gateway.networking.k8s.io/v1" you can skip generating an HTTPRoute when the Gateway API is not installed. .Files reads files inside the chart, and .Template gives the name of the file being rendered. To see them all at once we rewrote NOTES.txt; this file is a template too and is printed at the end of an install:
Bookstore API {{ .Chart.AppVersion }} (chart {{ .Chart.Name }}-{{ .Chart.Version }})
Release {{ .Release.Name }} in namespace {{ .Release.Namespace }}, revision {{ .Release.Revision }}{{ if .Release.IsUpgrade }} (upgrade){{ end }}
Cluster: Kubernetes {{ .Capabilities.KubeVersion.Version }}
Try it:
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "bookstore.fullname" . }}-api 8080:{{ .Values.service.port }}
curl http://127.0.0.1:8080/api/info
Seeing the Render: helm template and --dry-run=server
Two steps before sending anything to the cluster: helm lint checks the chart's structure and that the templates render, and helm template prints the rendered result.
helm lint bookstore
helm template bookstore ./bookstore -n bookstore-dev
==> Linting bookstore
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
---
# Source: bookstore/templates/serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: bookstore-api
labels:
helm.sh/chart: bookstore-0.1.0
app.kubernetes.io/name: bookstore
app.kubernetes.io/instance: bookstore
app.kubernetes.io/version: "1.0.0"
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/component: api
automountServiceAccountToken: false
...
---
# Source: bookstore/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookstore-api
...
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: bookstore
app.kubernetes.io/instance: bookstore
app.kubernetes.io/component: api
...
containers:
- name: api
image: "helmseries-bookstore-api:1.0.0"
helm template never connects to the cluster. So .Capabilities carries the defaults of the client library Helm was built with (Kubernetes 1.37 in Helm 4.3.2) and the lookup function always returns empty. To use real cluster information there is --dry-run=server:
kubectl create namespace bookstore-dev
helm install bookstore ./bookstore -n bookstore-dev --dry-run=server
NAME: bookstore
LAST DEPLOYED: Sun Nov 15 07:07:32 2026
NAMESPACE: bookstore-dev
STATUS: pending-install
REVISION: 1
DESCRIPTION: Dry run complete
TEST SUITE: None
HOOKS:
MANIFEST:
---
# Source: bookstore/templates/serviceaccount.yaml
...
NOTES:
Bookstore API 1.0.0 (chart bookstore-0.1.0)
Release bookstore in namespace bookstore-dev, revision 1
Cluster: Kubernetes v1.37.0
Because this cluster is also 1.37, both versions are the same here; on an older cluster the difference shows up at once, and helm template --kube-version 1.30.0 lets you simulate it. Let us also correct a misconception here: --dry-run=server does not pass the manifests through the API server's validation. We tried with an invalid replica count:
helm template bookstore ./bookstore --set replicaCount=-1 --show-only templates/deployment.yaml | grep replicas
helm install bookstore ./bookstore -n bookstore-dev --set replicaCount=-1 --dry-run=server | tail -3
replicas: -1
Try it:
kubectl -n bookstore-dev port-forward svc/bookstore-api 8080:8080
curl http://127.0.0.1:8080/api/info
Helm says "dry run complete". To see the server-side validation you have to give the render result to kubectl's server-side dry run:
helm template bookstore ./bookstore --set replicaCount=-1 | kubectl -n bookstore-dev apply --dry-run=server -f -
serviceaccount/bookstore-api created (server dry run)
service/bookstore-api created (server dry run)
The Deployment "bookstore-api" is invalid: spec.replicas: Invalid value: -1: must be greater than or equal to 0
In a real install the error does surface, but the resources sent before it stay in place:
helm install bookstore ./bookstore -n bookstore-dev --set replicaCount=-1
kubectl -n bookstore-dev get sa,svc,deploy
helm list -n bookstore-dev
Error: INSTALLATION FAILED: server-side apply failed for object bookstore-dev/bookstore-api apps/v1, Kind=Deployment: Deployment.apps "bookstore-api" is invalid: spec.replicas: Invalid value: -1: must be greater than or equal to 0
NAME AGE
serviceaccount/bookstore-api 5s
serviceaccount/default 26s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/bookstore-api ClusterIP <cluster-ip> <none> 8080/TCP 5s
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
bookstore bookstore-dev 1 2026-11-15 07:07:53.400995 +0300 +03 failed bookstore-0.1.0 1.0.0
The ServiceAccount and the Service were created, the Deployment was rejected, and the release is failed. Helm does not install as a single transaction; it sends the resources one after another. helm uninstall cleans up every resource in the release record. That is why a helm template | kubectl apply --dry-run=server step in CI is a cheap way to catch the error before it turns into a half-installed release in the cluster. In the sixth post we add schema validation with kubeconform.
Installing and the First Request
helm uninstall bookstore -n bookstore-dev
helm install bookstore ./bookstore -n bookstore-dev --wait
release "bookstore" uninstalled
NAME: bookstore
LAST DEPLOYED: Sun Nov 15 07:08:26 2026
NAMESPACE: bookstore-dev
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
Bookstore API 1.0.0 (chart bookstore-0.1.0)
Release bookstore in namespace bookstore-dev, revision 1
Cluster: Kubernetes v1.37.0
Try it:
kubectl -n bookstore-dev port-forward svc/bookstore-api 8080:8080
curl http://127.0.0.1:8080/api/info
# From inside the cluster, with a short-lived curl pod
kubectl -n bookstore-dev run curl --rm -i --restart=Never --image=curlimages/curl:8.17.0 -- \
-s http://bookstore-api:8080/api/info
{"version":"1.0.0","environment":"Development","pod":"bookstore-api-5b5b68b956-8cmv9","database":"InMemory","storeName":"Bookstore"}
/api/info is a small endpoint we added to the API for this series: it returns the image's version, the environment, the pod name and the database provider. The catalog answers too; the in-memory database has 20 seeded books:
{"items":[{"isbn":"9780451524935","title":"1984","author":"George Orwell","category":"Dystopian","price":279.00,"publishedYear":1949},{"isbn":"9780143035008","title":"Anna Karenina","author":"Leo Tolstoy","category":"Classics","price":429.00,"publishedYear":1878}],"total":20,"page":1,"pageSize":2}
Two Pitfalls: Fixed Names and a Fixed Image Tag
Not using the release name in resource names
Imagine you wrote name: bookstore-api in the templates. The first install works. When you want to install a preview environment into the same namespace, the second one collides:
# Names hard-coded in the templates: name: bookstore-api
helm install bookstore ./hardcoded -n bookstore-qa
helm install preview ./hardcoded -n bookstore-qa
NAME: bookstore
LAST DEPLOYED: Sun Nov 15 07:08:49 2026
NAMESPACE: bookstore-qa
...
Error: INSTALLATION FAILED: unable to continue with install: ServiceAccount "bookstore-api" in namespace "bookstore-qa" exists and cannot be imported into the current release: invalid ownership metadata; annotation validation error: key "meta.helm.sh/release-name" must equal "preview": current value is "bookstore"
Helm writes the name of the owning release on every resource with the meta.helm.sh/release-name annotation and refuses to adopt a resource that belongs to another release. That protection is a good thing; the problem is the fixed names. With the fullname helper:
helm install bookstore ./bookstore -n bookstore-qa
helm install preview ./bookstore -n bookstore-qa
kubectl -n bookstore-qa get deploy,svc -o name
deployment.apps/bookstore-api
deployment.apps/preview-bookstore-api
service/bookstore-api
service/preview-bookstore-api
An image tag that ignores appVersion
Now the scenario from Guess First. We raised version to 0.1.1 and appVersion to 1.1.0 in Chart.yaml and set the tag by hand during the upgrade:
# Chart.yaml: version 0.1.0 -> 0.1.1, appVersion "1.0.0" -> "1.1.0"
helm upgrade bookstore ./bookstore -n bookstore-dev --set image.tag=1.0.0 --wait
helm list -n bookstore-dev
kubectl -n bookstore-dev get deploy bookstore-api -o jsonpath='{.spec.template.spec.containers[0].image}'
NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION
bookstore bookstore-dev 2 2026-11-15 07:09:02.307547 +0300 +03 deployed bookstore-0.1.1 1.1.0
helmseries-bookstore-api:1.0.0
The API's /api/info answer says the same:
{"version":"1.0.0","environment":"Development","pod":"bookstore-api-5b5b68b956-8cmv9","database":"InMemory","storeName":"Bookstore"}
helm list says 1.1.0, the pod runs 1.0.0. The APP VERSION column is read from Chart.yaml only; it does not know what the image really is. The real surprise comes in the next step: we dropped the --set and upgraded again.
helm upgrade bookstore ./bookstore -n bookstore-dev --wait
kubectl -n bookstore-dev get deploy bookstore-api -o jsonpath='{.spec.template.spec.containers[0].image}'
helm get values bookstore -n bookstore-dev
helmseries-bookstore-api:1.0.0
USER-SUPPLIED VALUES:
image:
tag: 1.0.0
The image is still 1.0.0. When helm upgrade gets no -f or --set at all, Helm reuses the previous release's user values; the tag we set by hand is carried along silently. To return the values to the chart defaults you need --reset-values:
helm upgrade bookstore ./bookstore -n bookstore-dev --reset-values --wait
kubectl -n bookstore-dev get deploy bookstore-api -o jsonpath='{.spec.template.spec.containers[0].image}'
helmseries-bookstore-api:1.1.0
The answer to Guess First: the pod was running 1.0.0, and it was the Deployment itself and the application's version endpoint that said so, not helm list. We cover the merge rules of values and the --reuse-values family in detail in the fourth post. The practical rule: if you need to pin the image tag in an environment, write it into that environment's values file; do not leave it on the command line.
In Production
Bump the chart version on every change; shipping different content under the same version number makes it impossible to know what is installed in which cluster. Using an image digest instead of a tag in production protects against the same tag being pushed again; adding a field such as image.digest to the chart is the cleanest way to do it. helm lint only checks the chart's structure; it does not tell you whether Kubernetes will accept the result, and passing the render output through a server-side dry run closes that gap. Keep release names fixed per environment (bookstore in every environment); changing the name changes every resource name and with it the Service's DNS name.
Common Pitfalls
Using the helm create skeleton without trimming it and going to production with settings nobody knows. Hard-coding resource names and making a second install in the same namespace impossible. Writing appVersion without quotes and being surprised that 1.10 becomes 1.1. Taking APP VERSION in helm list as the version of the running image. Assuming --dry-run=server runs Kubernetes validation. Forgetting that a value passed on the command line while debugging is carried into every later upgrade. Putting a changing value such as a version into selector labels.
A Small Experiment
★☆☆ Install the release as shop instead of bookstore-dev and check the resource names. Then get the same names back with fullnameOverride=bookstore.
★★☆ Print the result of .Capabilities.APIVersions.Has "gateway.networking.k8s.io/v1" in NOTES.txt. Run it with helm template, --dry-run=client and --dry-run=server and see which one shows the real state of the cluster.
★★★ Add an image.digest value to the chart: when a digest is set, write the image as repository@sha256:..., otherwise use the tag. Find the digest of the image you loaded into kind with docker image inspect and install with the digest.
Our chart works, but the templates still lean on the helpers from helm create and there is no web front end. In the next post we look at Helm's template language in detail: functions, _helpers.tpl, include, flow control, whitespace, and a second component for bookstore-web.