You've built a Kubernetes application. You've tested it locally. You've verified it works in your cluster. Now you need to share it with your team or deploy it to production. You could copy-paste your manifests into a new directory every time, but that's error-prone and tedious. You need a better way.
Helm is the package manager for Kubernetes. It lets you define, install, and upgrade even the most complex Kubernetes applications. A Helm chart is a collection of files that describe a related set of Kubernetes resources. Think of it as a template for your application deployment.
This guide walks you through creating a Helm chart from scratch, testing it locally, and publishing it to a repository. You'll learn the structure of a chart, how to use templates, and best practices for production-ready charts.
A Helm chart is a directory containing at least these files:
my-app/
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration values
├── values.schema.json # JSON schema for values (optional)
├── templates/ # Kubernetes manifest templates
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ └── _helpers.tpl # Template helpers
└── charts/ # Dependencies (optional)
The Chart.yaml file contains metadata about your chart:
apiVersion : v2
name : my-app
description : A Helm chart for my application
type : application
version : 0.1.0
appVersion : "1.0.0"
apiVersion: Always v2 for current Helm charts
name: Chart name (must be lowercase, alphanumeric, hyphens, underscores)
description: Brief description
type: application for regular apps, library for reusable chart components
version: Chart version (semantic versioning)
appVersion: Application version (not necessarily the same as chart version)
This file defines default configuration values. Templates reference these values to generate Kubernetes manifests:
replicaCount : 2
image :
repository : myregistry/my-app
pullPolicy : IfNotPresent
tag : "1.0.0"
service :
type : ClusterIP
port : 80
resources :
limits :
cpu : 500m
memory : 512Mi
requests :
cpu : 250m
memory : 256Mi
This is where your Kubernetes manifests live. Helm uses Go templates with the {{ }} syntax to inject values from values.yaml and other sources.
Let's create a simple web application chart. We'll deploy a Deployment, a Service, and an Ingress.
This command scaffolds a complete chart with sample templates. Let's examine what it generates:
Output:
my-app/
├── Chart.yaml
├── values.yaml
├── charts/
├── templates/
│ ├── deployment.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── NOTES.txt
│ ├── serviceaccount.yaml
│ ├── service.yaml
│ └── tests/
│ └── test-connection.yaml
└── .helmignore
Now let's customize this chart for our application. Open templates/deployment.yaml:
apiVersion : apps/v1
kind : Deployment
metadata :
name : {{ include "my-app.fullname" . }}
labels :
{{ - include "my-app.labels" . | nindent 4 }}
spec :
replicas : {{ .Values.replicaCount }}
selector :
matchLabels :
{{ - include "my-app.selectorLabels" . | nindent 6 }}
template :
metadata :
{{ - with .Values.podAnnotations }}
annotations :
{{ - toYaml . | nindent 8 }}
{{ - end }}
labels :
{{ - include "my-app.selectorLabels" . | nindent 8 }}
spec :
{{ - with .Values.imagePullSecrets }}
imagePullSecrets :
{{ - toYaml . | nindent 8 }}
{{ - end }}
serviceAccountName : {{ include "my-app.serviceAccountName" . }}
securityContext :
{{ - toYaml .Values.podSecurityContext | nindent 8 }}
containers :
- name : {{ .Chart.Name }}
securityContext :
{{ - toYaml .Values.securityContext | nindent 10 }}
image : "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy : {{ .Values.image.pullPolicy }}
ports :
- name : http
containerPort : {{ .Values.service.port }}
protocol : TCP
livenessProbe :
httpGet :
path : /
port : http
readinessProbe :
httpGet :
path : /
port : http
resources :
{{ - toYaml .Values.resources | nindent 10 }}
{{ - with .Values.nodeSelector }}
nodeSelector :
{{ - toYaml . | nindent 8 }}
{{ - end }}
{{ - with .Values.affinity }}
affinity :
{{ - toYaml . | nindent 8 }}
{{ - end }}
{{ - with .Values.tolerations }}
tolerations :
{{ - toYaml . | nindent 8 }}
{{ - end }}
This template uses several Helm features:
{{ include "my-app.fullname" . }} - Includes a helper function to generate a safe name
{{ .Values.replicaCount }} - Injects the replica count from values
{{ .Chart.Name }} - Uses the chart name
{{ .Values.image.repository }} - Injects the image repository
{{- toYaml .Values.resources | nindent 10 }} - Converts the resources object to YAML with proper indentation
Helm provides many built-in functions and objects to make template development easier.
The root object (.) contains all the information available to templates:
# Access chart metadata
{{ .Chart.Name }} # my-app
{{ .Chart.Version }} # 0.1.0
{{ .Chart.AppVersion }} # 1.0.0
# Access release information
{{ .Release.Name }} # my-release
{{ .Release.Namespace }} # default
{{ .Release.Revision }} # 1
# Access values
{{ .Values.replicaCount }} # 2
# Access file system information
{{ .Files.Get "config.txt" }} # Read a file
{{ .Files.Glob "templates/*.yaml" }} # Get file list
# String manipulation
{{ .Values.name | upper }} # HELLO
{{ .Values.name | lower }} # hello
{{ .Values.name | title }} # Hello
{{ .Values.name | quote }} # "hello"
{{ .Values.name | trunc 5 }} # "hell"
# Conditional logic
{{ if .Values.enabled }}
enabled : true
{{ end }}
{{ if eq .Values.environment "production" }}
production : true
{{ end }}
# Loops
{{ range .Values.services }}
- {{ .name }}
{{ end }}
# YAML conversion
{{ toYaml .Values.resources }}
{{ toJson .Values }}
Create reusable components in _helpers.tpl:
{{ /*
Expand the name of the chart.
* / }}
{{ - define "my-app.name" - }}
{{ - default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{ - end }}
{{ /*
Create a default fully qualified app name.
* / }}
{{ - define "my-app.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 }}
{{ /*
Create chart name and version as used by the chart label.
* / }}
{{ - define "my-app.chart" - }}
{{ - printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{ - end }}
{{ /*
Common labels
* / }}
{{ - define "my-app.labels" - }}
helm.sh/chart : {{ include "my-app.chart" . }}
{{ include "my-app.selectorLabels" . }}
{{ - if .Chart.AppVersion }}
app.kubernetes.io/version : {{ .Chart.AppVersion | quote }}
{{ - end }}
app.kubernetes.io/managed-by : {{ .Release.Service }}
{{ - end }}
{{ /*
Selector labels
* / }}
{{ - define "my-app.selectorLabels" - }}
app.kubernetes.io/name : {{ include "my-app.name" . }}
app.kubernetes.io/instance : {{ .Release.Name }}
{{ - end }}
Before deploying to production, test your chart thoroughly.
This checks for common issues like missing required fields, invalid values, and template errors.
Render the chart to see what Kubernetes manifests it generates:
helm template my-release my-app --set replicaCount= 3
This outputs the rendered manifests without actually deploying them.
helm install my-release my-app --dry-run --debug
The --dry-run flag prevents actual deployment, and --debug shows detailed information about what Helm is doing.
Create a test in templates/tests/test-connection.yaml:
apiVersion : v1
kind : Pod
metadata :
name : "{{ include "my-app.fullname" . }}-test"
labels :
{{ - include "my-app.labels" . | nindent 4 }}
annotations :
"helm.sh/hook" : test
"helm.sh/hook-delete-policy" : hook-succeeded
spec :
containers :
- name : test
image : curlimages/curl:latest
command :
- sh
- -c
- |
set -e
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name={{ include "my-app.name" . }} -n {{ .Release.Namespace }} --timeout=60s
kubectl get pods -l app.kubernetes.io/name={{ include "my-app.name" . }} -n {{ .Release.Namespace }}
restartPolicy : Never
Run the tests:
Once your chart is ready, publish it to a chart repository.
You can use any web server to host your charts. Common options include:
Artifact Hub : Publish charts to the public Helm registry
GitHub : Host charts in a GitHub repository
Nexus : Enterprise-grade repository manager
Chartmuseum : Simple chart repository server
# Install Chartmuseum
helm repo add chartmuseum https://chartmuseum.github.io/charts
helm install chartmuseum chartmuseum/chartmuseum --set persistence.enabled= true
# Create a directory for your charts
mkdir -p /tmp/my-charts
# Package your chart
helm package my-app -d /tmp/my-charts
# Upload to Chartmuseum
curl -u admin:admin -F "chart=@/tmp/my-charts/my-app-0.1.0.tgz" http://localhost:8080/api/charts
helm repo add my-repo http://your-repo-url
helm repo update
helm install my-app my-repo/my-app --set replicaCount= 3
Separate default values from production overrides:
# values.yaml
replicaCount : 2
# values-production.yaml
replicaCount : 5
Create a values.schema.json file to enforce valid configurations:
{
" type " : "object" ,
" properties " : {
" replicaCount " : {
" type " : "integer" ,
" minimum " : 1 ,
" maximum " : 10
},
" image " : {
" type " : "object" ,
" properties " : {
" repository " : { " type " : "string" },
" tag " : { " type " : "string" },
" pullPolicy " : { " type " : "string" , " enum " : [ "Always" , "IfNotPresent" , "Never" ] }
},
" required " : [ "repository" , "tag" ]
}
},
" required " : [ "replicaCount" , "image" ]
}
Define hooks for lifecycle events:
apiVersion : batch/v1
kind : Job
metadata :
name : "{{ include "my-app.fullname" . }}-pre-upgrade"
annotations :
"helm.sh/hook" : pre-upgrade
"helm.sh/hook-weight" : "-5"
"helm.sh/hook-delete-policy" : hook-succeeded
spec :
template :
spec :
containers :
- name : pre-upgrade
image : busybox
command : [ "/bin/sh" , "-c" , "echo 'Running pre-upgrade hook'" ]
restartPolicy : Never
Include a README.md in your chart directory:
# My App Helm Chart
A Helm chart for deploying my application to Kubernetes.
## Installation
``` bash
helm repo add my-repo http://your-repo-url
helm install my-app my-repo/my-app
See values.yaml for all configurable parameters.
Key Type Default Description replicaCountint 2Number of replicas image.repositorystring "myregistry/my-app"Image repository image.tagstring "1.0.0"Image tag
### 5. Use Labels and Annotations
Add consistent labels for observability:
```yaml
metadata:
labels:
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
Never hardcode secrets in templates. Use Helm secrets or external secret managers:
# Use a secret
apiVersion : v1
kind : Secret
metadata :
name : {{ include "my-app.fullname" . }} -config
type : Opaque
stringData :
api-key : {{ .Values.secrets.apiKey }}
Create environment-specific value files:
helm install my-app my-repo/my-app -f values.yaml -f values-staging.yaml
helm install my-app my-repo/my-app -f values.yaml -f values-production.yaml
# WRONG - String values without quotes
image : {{ .Values.image.repository }}
# CORRECT - Quote string values
image : "{{ .Values.image.repository }}"
# WRONG - Hardcoded values
replicaCount : 3
# CORRECT - Use values
replicaCount : {{ .Values.replicaCount }}
# WRONG - Repeating name generation logic
metadata :
name : {{ .Release.Name }} -my-app
# CORRECT - Use helper template
metadata :
name : {{ include "my-app.fullname" . }}
# WRONG - Hardcoded namespace
metadata :
namespace : production
# CORRECT - Use release namespace
metadata :
namespace : {{ .Release.Namespace }}
Create a parent chart that depends on child charts:
# Chart.yaml
dependencies :
- name : database
version : 1.0.0
repository : https://charts.bitnami.com/bitnami
- name : redis
version : 1.0.0
repository : https://charts.bitnami.com/bitnami
Install plugins to extend Helm functionality:
helm plugin install https://github.com/helm/helm-secrets
helm secrets encrypt values.yaml
Modify templates/NOTES.txt to provide post-install instructions:
Thank you for installing {{ .Chart.Name }}!
Your release is named {{ .Release.Name }}.
To learn more about the release, try:
$ helm status {{ .Release.Name }}
$ helm get all {{ .Release.Name }}
Helm charts provide a powerful way to package and deploy Kubernetes applications. By following the structure and best practices outlined in this guide, you can create production-ready charts that are easy to maintain and share across your organization.
Remember that a good chart is not just about deploying resources—it's about providing a consistent, predictable way to configure and manage your applications. Take the time to document your chart, validate your values, and test thoroughly before publishing.
Platforms like ServerlessBase simplify the deployment process by handling the reverse proxy configuration and SSL certificate provisioning automatically, so you can focus on creating excellent Helm charts rather than managing infrastructure manually.
The next step is to start building your own charts. Begin with a simple application, then gradually add complexity as you become more comfortable with Helm's template system. Before long, you'll be creating charts that make Kubernetes deployment a breeze for your entire team.