Helm Charts: From Zero to Package Manager
Learn Helm from scratch: create charts, manage releases, use repositories, and implement best practices for Kubernetes package management.
Kubernetes YAML gets repetitive fast. Helm is the package manager that lets you template, version, and share Kubernetes manifests. Here’s everything you need to go from zero to managing production applications with Helm.
Why Helm?
Without Helm:
- Copy-paste YAML between environments
- Manual search-and-replace for configuration
- No versioning of deployments
- No easy rollback
With Helm:
- Templated manifests with values
- Versioned releases with history
- One-command rollbacks
- Reusable packages (charts)
Installing Helm
# macOS
brew install helm
# Linux
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Windows (chocolatey)
choco install kubernetes-helm
# Verify
helm version
Helm Concepts
| Concept | Description |
|---|---|
| Chart | Package of Kubernetes manifests |
| Release | Installed instance of a chart |
| Repository | Collection of charts |
| Values | Configuration for a chart |
Using Existing Charts
Add Repositories
# Add Bitnami repo (tons of production-ready charts)
helm repo add bitnami https://charts.bitnami.com/bitnami
# Add Prometheus community charts
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
# Update repos
helm repo update
# Search for charts
helm search repo nginx
helm search repo postgresql
Install a Chart
# Install with default values
helm install my-nginx bitnami/nginx
# Install with custom values
helm install my-nginx bitnami/nginx \
--set replicaCount=3 \
--set service.type=LoadBalancer
# Install with values file
helm install my-nginx bitnami/nginx -f custom-values.yaml
# Install to specific namespace
helm install my-nginx bitnami/nginx -n production --create-namespace
# Install specific version
helm install my-nginx bitnami/nginx --version 15.0.0
Manage Releases
# List releases
helm list
helm list -n production
helm list --all-namespaces
# Get release info
helm status my-nginx
helm get values my-nginx
helm get manifest my-nginx
# Upgrade release
helm upgrade my-nginx bitnami/nginx --set replicaCount=5
# Rollback
helm rollback my-nginx 1 # Rollback to revision 1
helm history my-nginx # View revision history
# Uninstall
helm uninstall my-nginx
Creating Your Own Chart
Chart Structure
# Create new chart
helm create my-app
# Structure
my-app/
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration
├── charts/ # Dependencies
├── templates/ # Kubernetes manifests
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ ├── _helpers.tpl # Template helpers
│ ├── NOTES.txt # Post-install notes
│ └── tests/
│ └── test-connection.yaml
└── .helmignore # Files to ignore
Chart.yaml
# Chart.yaml
apiVersion: v2
name: my-app
description: A Helm chart for my application
type: application
version: 0.1.0 # Chart version
appVersion: "1.0.0" # Application version
# Dependencies
dependencies:
- name: postgresql
version: "12.x.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
# Keywords for search
keywords:
- web
- application
maintainers:
- name: DevOps Team
email: [email protected]
values.yaml
# values.yaml - Default configuration
replicaCount: 1
image:
repository: my-app
tag: "latest"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: nginx
hosts:
- host: my-app.local
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
env: {}
secrets: {}
postgresql:
enabled: false
auth:
postgresPassword: ""
database: app
Template: deployment.yaml
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-app.fullname" . }}
labels:
{{- include "my-app.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "my-app.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
labels:
{{- include "my-app.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "my-app.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
{{- with .Values.env }}
env:
{{- range $key, $value := . }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- if .Values.secrets }}
envFrom:
- secretRef:
name: {{ include "my-app.fullname" . }}-secrets
{{- end }}
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
Template Helpers: _helpers.tpl
# templates/_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 }}
{{/*
Common labels
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.chart" . }}
{{ include "my-app.selectorLabels" . }}
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 }}
{{/*
Create the name of the service account to use
*/}}
{{- define "my-app.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "my-app.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
Template Syntax
Variables and Pipelines
# Basic variable
{{ .Values.replicaCount }}
# Pipeline (pass value through functions)
{{ .Values.image.tag | default "latest" | quote }}
# Conditional
{{- if .Values.ingress.enabled }}
# ingress manifest here
{{- end }}
# Range (loops)
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
{{- end }}
# With (scope)
{{- with .Values.resources }}
resources:
{{- toYaml . | nindent 2 }}
{{- end }}
Common Functions
# Default value
{{ .Values.tag | default "latest" }}
# Quote strings
{{ .Values.name | quote }}
# Indent/nindent
{{ toYaml .Values.resources | nindent 4 }}
# Required (fail if missing)
{{ required "image.repository is required" .Values.image.repository }}
# Lookup (get existing K8s resources)
{{ lookup "v1" "Secret" .Release.Namespace "my-secret" }}
# tpl (render string as template)
{{ tpl .Values.configTemplate . }}
# Include (use named template)
{{ include "my-app.fullname" . }}
Values Per Environment
values-production.yaml
# values-production.yaml
replicaCount: 3
image:
tag: "v1.2.3"
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
ingress:
enabled: true
hosts:
- host: app.company.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: app-tls
hosts:
- app.company.com
# Install with environment-specific values
helm install my-app ./my-app \
-f values.yaml \
-f values-production.yaml \
-n production
Chart Dependencies
Declaring Dependencies
# Chart.yaml
dependencies:
- name: postgresql
version: "12.x.x"
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: redis
version: "17.x.x"
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled
# Download dependencies
helm dependency update
# Dependencies stored in charts/
my-app/
└── charts/
├── postgresql-12.1.5.tgz
└── redis-17.0.1.tgz
Configuring Sub-charts
# values.yaml
postgresql:
enabled: true
auth:
postgresPassword: "secretpassword"
database: "myapp"
primary:
persistence:
size: 10Gi
redis:
enabled: true
architecture: standalone
auth:
enabled: true
password: "redispassword"
Testing Charts
Lint
# Validate chart syntax
helm lint ./my-app
# Lint with specific values
helm lint ./my-app -f values-production.yaml
Template Rendering
# Render templates locally (don't install)
helm template my-release ./my-app
# Render with values
helm template my-release ./my-app -f values-production.yaml
# Render specific template
helm template my-release ./my-app -s templates/deployment.yaml
Dry Run
# Simulate install
helm install my-release ./my-app --dry-run --debug
Chart Testing
# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "my-app.fullname" . }}-test-connection"
labels:
{{- include "my-app.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
# Run tests
helm test my-release
Helm Hooks
Execute actions at specific points in the release lifecycle:
# templates/pre-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ include "my-app.fullname" . }}-db-migrate"
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
template:
spec:
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["./migrate.sh"]
restartPolicy: Never
backoffLimit: 1
Hook types:
pre-install,post-installpre-upgrade,post-upgradepre-rollback,post-rollbackpre-delete,post-delete
Packaging and Publishing
Package Chart
# Create .tgz package
helm package ./my-app
# Package with specific version
helm package ./my-app --version 1.2.3
# Output: my-app-1.2.3.tgz
Host Repository
# Generate index.yaml for repository
helm repo index . --url https://charts.company.com
# Directory structure
charts/
├── index.yaml
├── my-app-1.0.0.tgz
├── my-app-1.1.0.tgz
└── my-app-1.2.0.tgz
GitHub Pages Repository
# .github/workflows/release.yml
name: Release Charts
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "[email protected]"
- name: Install Helm
uses: azure/setup-helm@v3
- name: Run chart-releaser
uses: helm/[email protected]
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
When NOT to Use Helm
- Simple, single-file manifests: kubectl apply is enough
- Kustomize does the job: overlays might be simpler for your use case
- GitOps with ArgoCD: ArgoCD can handle plain YAML or Kustomize
- Very dynamic config: Sometimes a script generating YAML is clearer
Key Takeaways
- Start with existing charts — don’t reinvent the wheel for databases, monitoring, etc.
- Use values files per environment — don’t hardcode anything
- Template everything configurable — image tags, replicas, resources
- Use
_helpers.tplfor reusable template functions - Always lint and template before install — catch errors early
- Version your charts — Chart.yaml version for chart changes, appVersion for app
- Use hooks for migrations and setup tasks
Helm isn’t just about templating—it’s about treating your Kubernetes manifests as proper software packages with versioning, dependencies, and distribution.