Convert Kubernetes YAML into a Helm Chart (Helm 4): Deployment and Service to values.yaml, Step by Step with helm template
TL;DR
- You do not rewrite your Deployment and Service when you move to Helm.
helm createalready generates templates that readimage.repository,image.tag,replicaCount,service.typeandservice.portfromvalues.yaml. For my Spring Boot demo, converting meant changing four keys invalues.yaml. - Render before you install.
helm template demo ./demochartprints the YAML the chart would send to the cluster, with no cluster and no kubeconfig. I used it after every edit and compared the result with my original manifests. - Four things go wrong: an empty
image.tagsilently falls back toappVersion(I gotrahulwagh17/kubernetes:1.16.0), the default liveness and readiness probes stay in your output, a typo in a template givesnil pointer evaluating interface {}.name, and one bad indent invalues.yamlmakeshelm lintstop withunable to load chart. - The chart in this post is 12 files and packages to a 4,954 byte
demochart-0.1.0.tgz. The two original manifests are 364 and 196 bytes; the chart renders 73 lines of YAML in about 1.2 seconds perhelm templaterun (average of 10 runs). - Not your exact case? If you only want to see how rendering works, read helm template explained; if you have never built a chart, start with getting started with Helm chart.
Table of Content
- What converting YAML to a Helm chart really means
- The two plain Kubernetes manifests I start from
- Generate the chart with helm create
- Render the untouched chart and read what it produces
- Convert with values only
- Make the change permanent in values.yaml
- Prove the chart equals the original manifests
- Lint and package the chart
- What broke when I ran this
- Conclusion
1. What converting YAML to a Helm chart really means
A Helm chart is a directory of Go templates plus a values.yaml that fills them in. Converting plain Kubernetes YAML means deciding which fields you want to change between environments, turning those fields into {{ .Values.something }} expressions, and putting their current values into values.yaml. Everything else stays as literal YAML.
The Helm chart template guide starts from helm create, and that is the route I recommend: you get a tested skeleton with helpers for names and labels, and you edit values instead of inventing template syntax. Older tutorials, including the first version of this post, rewrote deployment.yaml by hand. With Helm 4.3.1 the generated template already reads everything I need, so I do not touch it.
| Plain Kubernetes field | Where it lives in the chart | Key in values.yaml |
|---|---|---|
image: rahulwagh17/kubernetes:jhooq-k8s-springboot | templates/deployment.yaml | image.repository and image.tag |
replicas: 1 | templates/deployment.yaml | replicaCount |
containerPort: 8080 | templates/deployment.yaml | service.port |
type: NodePort | templates/service.yaml | service.type |
port: 8080 | templates/service.yaml | service.port |
metadata.name: demo | _helpers.tpl | nameOverride, fullnameOverride |
Note that the container port and the Service port share one key, service.port, in the generated chart. That is a convenient default for a demo and something to split later if your container and Service ports differ.
2. The two plain Kubernetes manifests I start from
These are the two files I want to turn into a chart. They deploy a small Spring Boot image that listens on 8080 and expose it as a NodePort Service. I trimmed the creationTimestamp: null and status: {} lines that kubectl create --dry-run adds, because they only add noise.
1# k8s-deployment.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 labels:
6 app: demo
7 name: demo
8spec:
9 replicas: 1
10 selector:
11 matchLabels:
12 app: demo
13 template:
14 metadata:
15 labels:
16 app: demo
17 spec:
18 containers:
19 - image: rahulwagh17/kubernetes:jhooq-k8s-springboot
20 name: kubernetes
21 ports:
22 - containerPort: 8080
1# k8s-service.yaml
2apiVersion: v1
3kind: Service
4metadata:
5 labels:
6 app: demo
7 name: demo-service
8spec:
9 ports:
10 - port: 8080
11 protocol: TCP
12 targetPort: 8080
13 selector:
14 app: demo
15 type: NodePort
You can apply them with kubectl apply -f against a real cluster, and the companion video deploys them before converting. Here I only need them as the reference to compare against, so nothing in this post touches a cluster. For how a Service of type NodePort behaves, see the Kubernetes Service documentation.
3. Generate the chart with helm create
I ran this with Helm 4.3.1 on 2026-10-28. helm create writes the skeleton and find lists what it produced.
1$ helm create demochart
2Creating demochart
3$ find demochart -type f | sort
4demochart/.helmignore
5demochart/Chart.yaml
6demochart/templates/NOTES.txt
7demochart/templates/_helpers.tpl
8demochart/templates/deployment.yaml
9demochart/templates/hpa.yaml
10demochart/templates/httproute.yaml
11demochart/templates/ingress.yaml
12demochart/templates/service.yaml
13demochart/templates/serviceaccount.yaml
14demochart/templates/tests/test-connection.yaml
15demochart/values.yaml
That is 12 files: one Chart.yaml, one values.yaml, one .helmignore and nine files under templates/. Compared with older tutorials, there is a new httproute.yaml for the Gateway API, and the generated values.yaml is 161 lines, mostly comments, while templates/deployment.yaml is 78 lines and _helpers.tpl is 62 lines. Chart files and their purpose are described in the Helm chart reference.
| File | What it does | Do I need it for this demo |
|---|---|---|
Chart.yaml | Chart name, version, appVersion | Yes, update the description |
values.yaml | Defaults for every template | Yes, this is where the conversion happens |
templates/deployment.yaml | The Deployment | Yes, unchanged |
templates/service.yaml | The Service | Yes, unchanged |
templates/serviceaccount.yaml | A ServiceAccount named after the release | Rendered, harmless |
templates/hpa.yaml, ingress.yaml, httproute.yaml | Optional objects, off by default | Not rendered |
templates/tests/test-connection.yaml | A test Pod for helm test | Rendered unless I pass --skip-tests |
templates/_helpers.tpl | Name and label helper templates | Yes, unchanged |
4. Render the untouched chart and read what it produces
Before editing anything I ask Helm what the generated chart renders to. I use grep to keep the output short.
1$ helm lint demochart
2==> Linting demochart
3[INFO] Chart.yaml: icon is recommended
4
51 chart(s) linted, 0 chart(s) failed
6$ helm template demo demochart | grep -n "kind:\|image:\|port\|Probe"
74:kind: ServiceAccount
818:kind: Service
929: ports:
1030: - port: 80
1141:kind: Deployment
1268: image: "nginx:1.16.0"
1370: ports:
1474: livenessProbe:
1577: port: http
1678: readinessProbe:
1781: port: http
1885:kind: Pod
Four objects come out: a ServiceAccount, a Service on port 80, a Deployment running nginx:1.16.0, and a test Pod. That is the starting point, and it shows exactly what I have to change: the image, the port, the Service type, and the probes that the generated chart adds and my manifest does not have. The helm template reference lists the flags I use below.
5. Convert with values only
Instead of editing values.yaml straight away, I put my overrides in a separate file, demo-values.yaml, and render with -f. That lets me see the effect of the whole conversion before I change any chart file.
1# demo-values.yaml
2image:
3 repository: rahulwagh17/kubernetes
4 tag: jhooq-k8s-springboot
5service:
6 type: NodePort
7 port: 8080
8livenessProbe: null
9readinessProbe: null
Setting a key to null in a values file removes the chart's default for it, which is how I drop the two probes. Here is the rendered Deployment and Service with Helm 4.3.1 on 2026-10-28:
1$ helm template demo demochart -f demo-values.yaml --show-only templates/deployment.yaml --show-only templates/service.yaml
2---
3# Source: demochart/templates/deployment.yaml
4apiVersion: apps/v1
5kind: Deployment
6metadata:
7 name: demo-demochart
8 labels:
9 helm.sh/chart: demochart-0.1.0
10 app.kubernetes.io/name: demochart
11 app.kubernetes.io/instance: demo
12 app.kubernetes.io/version: "1.16.0"
13 app.kubernetes.io/managed-by: Helm
14spec:
15 replicas: 1
16 selector:
17 matchLabels:
18 app.kubernetes.io/name: demochart
19 app.kubernetes.io/instance: demo
20 template:
21 metadata:
22 labels:
23 helm.sh/chart: demochart-0.1.0
24 app.kubernetes.io/name: demochart
25 app.kubernetes.io/instance: demo
26 app.kubernetes.io/version: "1.16.0"
27 app.kubernetes.io/managed-by: Helm
28 spec:
29 serviceAccountName: demo-demochart
30 containers:
31 - name: demochart
32 image: "rahulwagh17/kubernetes:jhooq-k8s-springboot"
33 imagePullPolicy: IfNotPresent
34 ports:
35 - name: http
36 containerPort: 8080
37 protocol: TCP
38---
39# Source: demochart/templates/service.yaml
40apiVersion: v1
41kind: Service
42metadata:
43 name: demo-demochart
44 labels:
45 helm.sh/chart: demochart-0.1.0
46 app.kubernetes.io/name: demochart
47 app.kubernetes.io/instance: demo
48 app.kubernetes.io/version: "1.16.0"
49 app.kubernetes.io/managed-by: Helm
50spec:
51 type: NodePort
52 ports:
53 - port: 8080
54 targetPort: http
55 protocol: TCP
56 name: http
57 selector:
58 app.kubernetes.io/name: demochart
59 app.kubernetes.io/instance: demo
Compared with my hand-written manifests, the image, the replica count, the container port, the Service type and the Service port all match. What differs is metadata: the chart adds standard app.kubernetes.io/* labels and names the objects <release>-<chart> (demo-demochart) instead of demo and demo-service. Two things matter here. The selector now uses app.kubernetes.io/name and app.kubernetes.io/instance instead of app: demo, and the Service's targetPort is the named port http instead of the number 8080. Both are equivalent as long as the Deployment names its port http, which the generated template does.
If you want to keep the exact original names, set fullnameOverride: demo in the values file. I left the generated names alone because the release prefix is what lets you install the same chart twice in one namespace.
To pass many overrides across environments, see my posts on multiple values files and passing environment variables to a chart.
6. Make the change permanent in values.yaml
Once the override file renders what I want, I move the same four changes into the chart's own values.yaml so that helm install demo ./demochart works without -f. The diff against the generated file is:
1- repository: nginx
2+ repository: rahulwagh17/kubernetes
3...
4- tag: ""
5+ tag: jhooq-k8s-springboot
6...
7- type: ClusterIP
8+ type: NodePort
9...
10- port: 80
11+ port: 8080
12...
13-livenessProbe:
14- httpGet:
15- path: /
16- port: http
17-readinessProbe:
18- httpGet:
19- path: /
20- port: http
21+livenessProbe: {}
22+readinessProbe: {}
The probes become empty maps, and the template wraps each probe in {{- with .Values.livenessProbe }}, which skips empty values. My Spring Boot demo has no health endpoint, so I disable them. For a real service I would keep both and point them at /actuator/health or whatever your app exposes. I also changed the description in Chart.yaml to Spring Boot demo converted from plain Kubernetes YAML.
I also want to flag what I did not change: appVersion stays at 1.16.0. It only matters when image.tag is empty, which is the first trap in section 9.
7. Prove the chart equals the original manifests
Reading YAML by eye is how mistakes slip through, so I compared the rendered objects with the original files by parsing both. This is a short Python check I ran against the output of helm template demo demochart --skip-tests (Python with PyYAML, 2026-10-28):
1$ python3 -I check.py
2True True True
3True True
The first line compares the image, the container port and the replica count of the Deployment; the second compares the Service type and port. All five are equal, so for the fields I chose to parameterise, the chart is a faithful conversion of my manifests. It is not a replacement for helm install --dry-run=server against a real API server, which also checks schema and admission rules; I cover that in helm install dry run.
8. Lint and package the chart
helm lint checks the chart for errors and convention breaks, and helm package builds the archive you push to a repository. Both ran on Helm 4.3.1 on 2026-10-28:
1$ helm lint demochart
2==> Linting demochart
3[INFO] Chart.yaml: icon is recommended
4
51 chart(s) linted, 0 chart(s) failed
6$ helm template demo demochart --skip-tests | grep -c "^kind"
73
8$ helm package demochart
9Successfully packaged chart and saved it to: demochart-0.1.0.tgz
10$ ls -l demochart-0.1.0.tgz | awk '{print $5,$9}'
114954 demochart-0.1.0.tgz
The icon is recommended line is an INFO, not an error; lint exits successfully. --skip-tests leaves three objects (ServiceAccount, Service, Deployment) because it drops the test Pod. The archive name comes from name and version in Chart.yaml, so bumping version to 0.2.0 produces demochart-0.2.0.tgz. The helm lint and helm package pages document the flags.
| Command | What it proves | What it does not prove |
|---|---|---|
helm lint | The chart loads, values parse, templates render | The objects are valid for your cluster version |
helm template | The exact YAML Helm would send | That the API server accepts it |
helm package | The chart archives into one .tgz | Nothing about runtime behaviour |
helm install --dry-run=server | The API server accepts it (needs a cluster) | That the pods become ready |
9. What broke when I ran this
These are the four mistakes I made or provoked while building this chart, with the exact output.
1. I forgot the tag and got a different image. If you set only image.repository, the template falls back to the chart's appVersion. There is no error:
1$ helm template demo demochart --set image.repository=rahulwagh17/kubernetes --show-only templates/deployment.yaml | grep image:
2 image: "rahulwagh17/kubernetes:1.16.0"
The tag 1.16.0 does not exist for my image, so the pod would sit in ImagePullBackOff. The fix is to set image.tag or to bump appVersion in Chart.yaml to the real application version. Render and read the image: line before every install.
2. The default probes stayed in my output. The first render I did with service.port=8080 still contained a livenessProbe and readinessProbe on path /:
1$ helm template demo demochart --set service.port=8080 --show-only templates/deployment.yaml | grep -n -A3 Probe | head -12
237: livenessProbe:
338- httpGet:
439- path: /
540- port: http
641: readinessProbe:
742- httpGet:
843- path: /
944- port: http
The old tutorials edited deployment.yaml to remove them. In Helm 4.3.1 they live in values.yaml, so the fix is livenessProbe: {} (or null in an override file), not a template edit.
3. A typo in a template is a hard error with a line number. I changed .Values.image.repository to .Values.image.repo.name in a copy of the chart:
1$ helm template demo badchart
2Error: demochart/templates/deployment.yaml:41:28
3 executing "demochart/templates/deployment.yaml" at <.Values.image.repo.name>:
4 nil pointer evaluating interface {}.name
5
6Use --debug flag to render out invalid YAML
7$ helm lint badchart
8==> Linting badchart
9[INFO] Chart.yaml: icon is recommended
10[ERROR] templates/: demochart/templates/deployment.yaml:41:28
11 executing "demochart/templates/deployment.yaml" at <.Values.image.repo.name>:
12 nil pointer evaluating interface {}.name
.Values.image.repo does not exist, so asking for .name on it is a nil pointer. The message points at line 41, column 28 of the template. helm lint reports the same location as [ERROR], which is why I run it in CI.
4. One bad indent in values.yaml breaks the whole chart. I appended a service: block with a misaligned port to a copy of the chart's values file:
1$ helm lint bad2
2==> Linting bad2
3[INFO] Chart.yaml: icon is recommended
4[ERROR] values.yaml: unable to parse YAML: error converting YAML to JSON: yaml: line 164: mapping values are not allowed in this context
5[ERROR] templates/: cannot load values.yaml: cannot unmarshal yaml document: error converting YAML to JSON: yaml: line 164: mapping values are not allowed in this context
6[ERROR] : unable to load chart
7 cannot load values.yaml: cannot unmarshal yaml document: error converting YAML to JSON: yaml: line 164: mapping values are not allowed in this context
The line number points at the first line the YAML parser could not read, here 164, which is the bad port: line. Because values are parsed before any template runs, you get three errors for one mistake.
5. Two smaller surprises. Running helm template demoHelmChart from the wrong directory fails with a message that has nothing to do with the real problem, because Helm treats the argument as a repository path:
1$ helm template demoHelmChart
2Error: non-absolute URLs should be in form of repo_name/path_to_chart, got: demoHelmChart
And if you mistakenly pass --set service.port=http the Service renders - port: http, which the API server will reject because a port must be a number. Helm does not know the type of a Kubernetes field, so numbers and strings are your responsibility.
10. Conclusion
To summarise what I would do again:
- Start from
helm createand changevalues.yaml; edit the templates only when a field is not parameterised. - Render with
helm templateafter every change and read theimage:and port lines. - Put overrides in a separate values file first, then move them into the chart once the render looks right.
- Run
helm lintand a structured comparison with the original manifests before you package. - Split
service.portinto a container port and a Service port as soon as they differ.
For the full flow from install to upgrade and rollback, see my Helm chart WordPress installation post, and watch the companion video for the same conversion done live against a cluster.
Read More -
- Helm chart - How to Add/Install plugins
- Getting started with Helm Chart
- Helm chart - WordPress Installation with MariaDB on Kubernetes
- Helm chart - Build you first helm chart with Spring Boot
- Helm Chart - Convert Kubernetes YAML into Helm Chart YAML
- Helm Chart - Pass environment variables
- Helm Chart - Plugin
- Helm Chart - Dry Run Install
- Helm Chart - How to create multiple values files inside helm chart?
- Helmfile - How to use Helmfile for managing helm chart?
- Helm template - Render a chart locally without a cluster (Helm 4)
Did this save you a debugging session? Stay in the loop -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.