1. Introduction

In this post we extend the simple Kubernetes cluster from the previous article by adding Helm charts to it. I start with a few changes to the environment, then move on to a minimal working example with Helm, showing the basic chart structure and how to perform a first deployment. The goal is to demonstrate that Helm can be used in a very straightforward way, while at the same time providing a solid foundation for more complex scenarios.

2. What is Helm

Helm is a package manager for Kubernetes, designed to simplify the process of deploying and managing applications. Kubernetes often requires many YAML manifests describing various resources: Deployments, Services, ConfigMaps, Ingresses and more. Helm lets you gather them into a single, cohesive structure that can be easily installed, upgraded and removed. Helm works as a layer on top of Kubernetes — it generates manifests and then applies them to the cluster. The biggest benefit is the ability to parameterize and reuse the same definitions across different environments.

2.1 What are Helm Charts

Helm charts are packages that describe an application or part of one in Kubernetes. You can think of them as a project containing all the files needed to deploy a specific component. A typical chart consists of four main elements:

  • Chart – chart metadata: name, version, description, API version.
  • Values – a file with configuration parameters.
  • templates/ directory – Kubernetes manifest templates (Deployment, Service, ConfigMap, etc.).
  • charts/ directory – dependencies (usually empty in simple projects).

A chart is an organized collection of files, enabling predictable and repeatable application deployments. It works equally well for small experiments and large production environments.

3. Implementation

The source code for this blog series is available in the GitHub repository:
https://github.com/mpiotro4/k8s_demo/tree/blog/2024-11-25-helm

3.1 A few changes upfront

First, a couple of changes compared to the previous post. For starters, our simple API has become a bit less simple — it now displays some additional information:

mpio@Marcins-MacBook-Air ~ % curl localhost:8080 | jq
{
"hostname": "Marcins-MacBook-Air.local",
"message": "default message",
"namespace": null,
"node_name": null,
"pod_ip": null,
"status": "ok"
}

I also switched from a Rancher cluster to Kind.

mpio@Marcins-MacBook-Air ~ % kind create cluster
Creating cluster "kind" ...
✓ Ensuring node image (kindest/node:v1.34.0) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-kind"

Since Kind doesn't have a built-in load balancer, the LoadBalancer type doesn't work — the Service stays in a "pending" state. The simplest workaround is kubectl port-forward:

kubectl port-forward service/demo-api 8080:8080

After forwarding the ports, the application works inside the cluster:

{
"hostname": "demo-api-7df9444d9c-f2w47",
"namespace": "default",
"node_name": "kind-control-plane",
"pod_ip": "10.244.0.5",
"status": "ok"
}

3.2 Minimal example

To get started with Helm, we generate a sample chart:

helm create simple-chart

This creates a full "starter chart" with many files. To simplify the project, we remove the unnecessary parts and keep only:

simple-chart/
├── Chart.yaml
├── values.yaml
└── templates/
    ├── deployment.yaml
    └── service.yaml

Chart.yaml holds metadata, values.yaml can store configuration parameters, and the templates/ directory contains the manifest templates. Now we can install the chart:

helm install my-release-name ./demo-chart

As a result, the application gets deployed to the cluster, just like in the previous post.

3.3 A slightly less minimal example

It's worth organizing names and parameters instead of repeating the same values in multiple places. The deployment.yaml example shows the name demo-api being repeated. A better approach is to move those values to values.yaml.

appName: demo-api
config:
  APP_PORT: "8080"
  APP_MESSAGE: "Hello from Helm Values"

In the Deployment template, you can now reference them via .Values:

metadata:
  name: { { .Values.appName } }
  labels:
    app: { { .Values.appName } }

The same change is applied to configmap.yaml. After updating the chart, we run:

helm upgrade my-release-name ./demo-chart

For the Pods to pick up the new values, we need to force a Deployment rollout:

kubectl rollout restart deployment demo-api

After the restart:

{
    "hostname": "demo-api-865cc7f5d4-8bhkl",
    "message": "Hello from Helm Values",
    "namespace": "default",
    "node_name": "kind-control-plane",
    "pod_ip": "10.244.0.20",
    "status": "ok"
}

4. Environment separation

Now that we've had our first taste of Helm values, we can move on to something more practical: deploying the same application across multiple environments. This is a very common practice in team-based organizations, as it allows changes to be developed, tested and verified without impacting end users. A typical environment breakdown looks like this:

  • PROD – the production environment used by end users,
  • DEV – the development environment used for ongoing work and testing changes,
  • QA – the environment intended for QA teams, where the application is tested before being promoted to production.

With Helm values, we can define different parameters for each environment, such as:

  • Docker image version,
  • application configuration (e.g. environment variables),
  • integrations with external systems (e.g. mocks instead of real services).

Practical note:
In this example, the environments (DEV/QA/PROD) are separated using namespaces within a single Kubernetes cluster. This approach is commonly used locally, in small teams, and for educational purposes, as it is cheap and easy to maintain.
In production environments, however, a different model is more common, where each environment runs in a separate Kubernetes cluster (e.g. a dedicated cluster for DEV, STAGE and PROD), and namespaces are used primarily to separate applications, teams or tenants within a single environment.
For the purposes of this post, namespaces serve as environments, which allows us to focus on the mechanics of Helm values without introducing additional infrastructure complexity.

4.1 Implementation

To start, I extended the values.yaml file from the previous section with a new namespace parameter. This will be responsible for logically separating the individual environments within the Kubernetes cluster.

appName: demo-api
config:
  name: demo-api-config
  APP_PORT: 8081
  APP_MESSAGE: "Hello from Helm Values"
namespace: "default"
image:
  name: kddny/demo_api
  tag: latest

In the base values.yaml, the namespace parameter is set to default, but in practice the default namespace is rarely used for applications. Instead, we create separate override files for specific environments. A sample values-dev.yaml file for the DEV environment might look like this:

config:
  APP_MESSAGE: "Hello from DEV"
namespace: dev

Here I only defined the values that differ from the defaults. All other fields are automatically inherited from the main values.yaml. The same approach can be used to prepare values-qa.yaml and values-prod.yaml files. To deploy the application in the DEV environment, I use the helm install command with additional parameters:

helm install my-release-name-dev ./demo-chart \
  --values ./demo-chart/values.yaml \
  -f ./demo-chart/values-dev.yaml \
  -n dev

What each part means:

  • my-release-name-dev – the release name, unique within the cluster,
  • ./demo-chart – path to the Helm chart,
  • --values / -f – value files that Helm merges into a single configuration (later files override earlier ones),
  • -n dev – the namespace where the resources will be created.

I deployed the application to the PROD environment in the same way, using a separate values file and a different release name. Finally, we just need to expose the appropriate ports locally. For the DEV environment I forward port 8081:

kubectl port-forward -n dev service/demo-api 8081:8081

This command means:

  • -n dev – the namespace where the Service is located,
  • service/demo-api – the name of the Service resource,
  • 8081:8081 – mapping the local port to the service port in the cluster.

After starting the port-forward, we can verify the application is working:

curl localhost:8081 | jq
{
  "hostname": "demo-api-5cd7597b5c-mdfmt",
  "message": "Hello from DEV",
  "namespace": "dev",
  "node_name": "kind-control-plane",
  "pod_ip": "10.244.0.22",
  "status": "ok"
}

The production environment works the same way, running in a different namespace with a different configuration:

curl localhost:8082 | jq
{
  "hostname": "demo-api-5cd7597b5c-22l9m",
  "message": "Hello from PROD",
  "namespace": "prod",
  "node_name": "kind-control-plane",
  "pod_ip": "10.244.0.23",
  "status": "ok"
}

With this approach, a single Helm chart definition can serve multiple environments that differ only in configuration, not in code or Kubernetes manifests.

5. Summary

This post covered the basics of working with Helm: from creating a minimal chart, through parameterizing manifests with values.yaml, to deploying an application across multiple environments. Helm doesn't replace Kubernetes, but it simplifies manifest management, eliminates duplication and makes application updates easier. As a result, the same chart definition can be reused multiple times, changing only the configuration.