Your Kubernetes cluster is in production. Every deployment is a copy-paste of YAML files? Manifest files scattered across folders, hardcoded values, zero traceability. We see this often in projects we review: functioning clusters managed like prototypes. A syntax error in a Deployment and your app stays in CrashLoopBackOff for hours. The time spent fixing it is time you could have spent on your product.
We, at Meteora Web, have been working with Kubernetes for years — long before containers became the norm. We've seen companies save hundreds of hours simply by structuring deployments with Helm. And we've also seen the economic side: a failed manual deploy on a production cluster costs more than a day of development time. With Helm, deployment becomes reproducible, versioned, testable. And you pay for it once.
This guide is the second spoke of our pillar on Kubernetes and container orchestration. We'll take you inside Helm: from chart structure to best practices for real environments. No abstract theory — everything you need to stop writing YAML by hand and start using a proper package manager.
Why Helm Is Not Just a Nice-to-Have
Helm is the package manager for Kubernetes. But don't just think of it as 'apt for clusters'. It's much more: a templating engine, a release management system, a repository of ready-to-use packages. Without it, every deployment is a manual assembly: copy a YAML, paste, change namespace, modify name, cross your fingers. With Helm you define the structure once and parameterize it. The result? The same chart deployed in development, staging, and production with different values, zero code duplication.
The analogy we use with our clients is the cash register in a store: if you have to calculate every receipt by hand, you waste time and make mistakes. If you have an ERP that does it for you with fixed rules, you gain precision and speed. Helm is your ERP for Kubernetes.
Sponsored Protocol
The Cost of Not Using Helm
A client brought us a cluster with 15 microservices, each with its own folder of YAML files. Every deployment required between 20 and 40 minutes of human attention. A typo in a volume name — and the pod wouldn't start. Helm reduced deployment to a single helm upgrade --install command. The savings? About 15 hours per month. That's not just technology — it's economics.
Anatomy of an Helm Chart
A chart is a structured directory. If you don't respect it, Helm won't parse it. Here are the mandatory and typical files:
my-chart/
├── Chart.yaml # chart metadata (name, version, dependencies)
├── values.yaml # default values for templates
├── templates/ # Go template files that generate YAML
│ ├── deployment.yaml
│ ├── service.yaml
│ └── _helpers.tpl # reusable functions (underscore = no resource generated)
├── charts/ # dependencies (nested charts)
└── .helmignore # files to exclude
Chart.yaml is the identity card. Minimal example:
apiVersion: v2
name: my-webapp
description: A simple web server
version: 0.1.0
appVersion: "1.16.0"
values.yaml is where you put values that change per environment: replicaCount, image, port, resources. It's the heart of parameterization.
templates/ contains files processed by the Go template engine. Any file starting with _ does not produce a Kubernetes object but can be included by other templates (e.g., helpers for common labels).
Common mistake: forgetting to version control charts in git. The chart must be under version control, exactly like code. Otherwise you lose reproducibility.
Create Your First Chart from Scratch
All you need is a terminal. Use helm create to generate a sample structure, but we prefer to start from zero to understand everything.
Sponsored Protocol
Step 1: the directory
mkdir nginx-private
cd nginx-private
mkdir templates
Step 2: Chart.yaml
apiVersion: v2
name: nginx-private
description: My custom nginx with parameterized values
version: 0.1.0
appVersion: "1.25.0"
Step 3: values.yaml
replicaCount: 2
image:
repository: nginx
tag: "1.25.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources: {}
nodeSelector: {}
Step 4: deployment.yaml in templates/
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "nginx-private.fullname" . }}
labels:
{{- include "nginx-private.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "nginx-private.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "nginx-private.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: nginx
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: {{ .Values.service.port }}
Step 5: service.yaml
apiVersion: v1
kind: Service
metadata:
name: {{ include "nginx-private.fullname" . }}
labels:
{{- include "nginx-private.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.port }}
protocol: TCP
name: http
selector:
{{- include "nginx-private.selectorLabels" . | nindent 4 }}
Step 6: _helpers.tpl (standard names and labels)
{{- define "nginx-private.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "nginx-private.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 }}
{{- define "nginx-private.labels" -}}
helm.sh/chart: {{ include "nginx-private.name" . }}-{{ .Chart.Version | replace "+" "_" }}
{{ include "nginx-private.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "nginx-private.selectorLabels" -}}
app.kubernetes.io/name: {{ include "nginx-private.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Now you can install the chart with:
Sponsored Protocol
helm install my-nginx ./nginx-private
Go Template Deep Dive: What You Really Need
Helm templates use Go language with Sprig functions. You don't need to be a Go expert, but you need to know the key functions.
Accessing values
.Values.replicaCount — the dot refers to the global object. .Chart, .Release, .Files are other built-in objects.
Conditionals
Useful for exposing services only when needed:
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
...
{{- end }}
Range (loops)
To iterate over a list, for example multiple environment variables:
env:
{{- range $key, $val := .Values.envVars }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
String manipulation functions
upper, lower, trimSuffix, quote, default, required. required is great for validating that a mandatory value is present:
domain: {{ required "A value for domain is required" .Values.domain }}
Whitespace and indentation
Use {{- to trim preceding whitespace and -}} to trim trailing. nindent adds a newline and indentation. Golden rule: for items in a list, use nindent to keep valid YAML.
Sponsored Protocol
Managing Dependencies: Charts Containing Charts
Your webapp needs a database? Instead of writing a Deployment for MySQL by hand, you can declare a dependency on an official chart (e.g., bitnami/mysql).
Chart.yaml with dependencies
apiVersion: v2
name: webapp-with-db
dependencies:
- name: mysql
version: "9.10.2"
repository: "https://charts.bitnami.com/bitnami"
condition: mysql.enabled
tags:
- database
Then run:
helm dependency update
It downloads the chart into the charts/ directory. Now you can pass configuration values for MySQL in your values.yaml under the mysql key.
Caution: dependencies are not updated automatically. When the parent chart version changes, you must explicitly run helm dep update again.
Deploy and Rollback: The Command That Saves the Day
The Helm lifecycle is managed with helm install, helm upgrade, helm rollback. Each deployment is a release with a revision number.
Installation
helm install my-webapp ./my-chart -f values-production.yaml --namespace production --create-namespace
Upgrade
helm upgrade my-webapp ./my-chart -f values-production.yaml --atomic --timeout 10m
The --atomic flag rolls back the previous release if the upgrade fails. Don't fly without a net.
Rollback
helm rollback my-webapp 2 # go back to revision 2
helm history my-webapp # list revisions
Best Practices for Custom Charts
- Use standard naming:
app.kubernetes.io/name,app.kubernetes.io/instance,app.kubernetes.io/version. Monitoring and networking tools expect these labels. - Validate values with JSON Schema: create a
values.schema.jsonfile at the chart root. Helm uses it to validate values before rendering. A simple schema prevents errors likereplicaCount: "three". - Don't hardcode names: always use helpers to generate fullname. Avoids collisions in shared namespaces.
- Separate values per environment: files like
values-dev.yaml,values-prod.yamlare separate from the chart. The chart is the single source of truth for structure; values are configuration. - Update appVersion: when the container tag changes, update
appVersionin Chart.yaml and incrementversion. That way the operations team instantly knows if the chart is aligned.
Integration with CI/CD
Helm shines in automated pipelines. Once the container image is built, the pipeline can run helm upgrade --install with the right values. We dive deeper into this topic in our dedicated CI/CD article.
Sponsored Protocol
We also recommend reading our Kubernetes pillar guide to frame Helm in the full orchestration context.
In Summary — What to Do Now
- Convert one of your manual deployments into an Helm chart. Take the simplest Deployment, Service, and ConfigMap you have and create a chart following the steps in this guide.
- Parameterize values: move into values.yaml everything that changes per environment (replicas, image, environment variables, resources).
- Add helpers for names: don't use fixed names, use
include "your-chart.fullname" . - Add a JSON Schema: a simple
values.schema.jsonfile at the chart root reduces configuration errors. - Integrate Helm into your CI/CD pipeline: if you already use GitLab CI, GitHub Actions, or Jenkins, add a step that runs
helm upgrade --install --atomic.
And if you have doubts about your cluster, the hidden costs of manual deployment, or the security of your manifests, let's talk. We've been working with Kubernetes long before it was fashionable.