Mert Özen Further With Every Line
DevOps Helm — #02

Your First Chart: Packaging Bookstore.Api with helm create, Chart.yaml, templates/ and values.yaml

Mert Özen Nov 15, 2026 13 min 8 views
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.