Raw Kubernetes manifests work fine when you have three or four YAML files for a simple application. They stop working well the moment you need to deploy the same application to multiple environments with different configurations, share it with someone else, or roll back a botched upgrade at 11pm. A Deployment for production needs a different image tag than staging. Ingress hostnames differ between environments. Resource limits are tuned differently. You end up copy-pasting YAML and hand-editing values, which is exactly the kind of process that leads to configuration drift and outages.
Helm solves this. It is the package manager for Kubernetes — the same idea as apt or brew, but for cluster workloads. You define a chart once with sensible defaults, and then install it multiple times with different values. Upgrades are tracked, rollbacks are one command, and the entire configuration can live in version control. This guide covers everything: how Helm works, how to build a chart from scratch, templating in depth, dependency management, production release management, and the community charts worth knowing for a homelab stack.
What Is Helm and Why Use It?
The problem with raw manifests at scale
A non-trivial Kubernetes application typically consists of a Deployment, a Service, an Ingress, a ConfigMap, one or more Secrets, a ServiceAccount, possibly an HPA, and often a PersistentVolumeClaim. That is seven or more YAML files before you’ve done anything unusual. Multiply that across staging and production, and you have 14+ files that are nearly identical except for a handful of values. Keeping them synchronized manually is error-prone and tedious.
Helm solves this by introducing templating — your manifests become parameterized templates, and a values.yaml file provides the defaults. You install the same chart into any environment by overriding only what differs.
Helm concepts
Four concepts sit at the core of Helm:
- Chart: The package — a directory of templates, a
values.yaml with defaults, and metadata.
- Release: A named instance of a chart installed in a cluster. You can install the same chart twice with different release names and different values.
- Repository: A collection of charts, analogous to an apt repository or a container registry.
- Values: The configuration layer. Override chart defaults without touching the templates.
Helm 3 vs Helm 2: Tiller is gone
Helm 2 required a server-side component called Tiller, which ran in the cluster with cluster-admin permissions. This was a significant security problem — anything that could talk to Tiller could do anything to the cluster. Helm 3 (released November 2019) eliminated Tiller entirely. Helm 3 is a pure client-side tool that talks directly to the Kubernetes API using your kubeconfig credentials. Release state is stored in Kubernetes Secrets inside the namespace where the chart is installed, which means RBAC applies normally. If you are still using Helm 2 for any reason, migrate.
When to use Helm vs Kustomize vs raw manifests
| Situation |
Best tool |
| Simple personal app, full control |
Raw manifests |
| Patching upstream manifests (e.g., adding labels to someone else’s Deployment) |
Kustomize |
| Installing third-party software (databases, ingress, monitoring) |
Helm |
| Multi-environment deployment of your own app with many config options |
Helm |
| Simple multi-environment overlays without templating |
Kustomize |
| Distributing an app for others to install |
Helm |
These tools are not mutually exclusive. A common pattern is to use Helm for third-party software and Kustomize for your own applications, or to use Helm for everything and manage values files per environment.
Helm Architecture and Core Concepts
Chart structure
Running helm create myapp produces this scaffold:
myapp/
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration values
├── charts/ # Packaged chart dependencies (subcharts)
├── templates/
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── hpa.yaml
│ ├── serviceaccount.yaml
│ ├── _helpers.tpl # Named templates / helper partials
│ ├── NOTES.txt # Post-install instructions printed to stdout
│ └── tests/
│ └── test-connection.yaml
└── .helmignore # Files to exclude when packaging
Every file in templates/ is processed through Go’s text/template engine when you run helm install or helm template. Files prefixed with _ (like _helpers.tpl) are not rendered as manifests — they are partials that define reusable named templates.
Chart.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
apiVersion: v2
name: myapp
description: A Helm chart for my web application
type: application
version: 0.3.1 # Chart version — bump on every chart change
appVersion: "1.14.2" # The version of the application being packaged
maintainers:
- name: Jane Smith
email: jane@example.com
dependencies:
- name: postgresql
version: "13.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
|
type can be application (produces deployable manifests) or library (provides only named templates, cannot be installed directly). version uses SemVer and controls what Helm tracks as the chart version. appVersion is informational — it typically tracks the upstream application version bundled in the chart.
Release storage
When you install a chart, Helm creates a Secret in the target namespace named sh.helm.release.v1.<release-name>.v<revision>. This Secret encodes the complete release state: the rendered manifest, the values used, and metadata. This is how helm rollback works — it retrieves a previous revision’s data and re-applies it.
Essential Helm Commands
Repository management
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# Add a repository
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add cert-manager https://charts.jetstack.io
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
# Update all repos (like apt-get update)
helm repo update
# List configured repos
helm repo list
# Remove a repo
helm repo remove bitnami
|
Searching for charts
1
2
3
4
5
6
|
# Search your configured repos
helm search repo nginx
helm search repo postgresql --versions # Show all available versions
# Search Artifact Hub (artifacthub.io) — requires internet access
helm search hub prometheus
|
Installing charts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
# Basic install
helm install my-nginx ingress-nginx/ingress-nginx
# Install into a specific namespace, creating it if needed
helm install my-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace
# Override individual values inline
helm install my-release bitnami/postgresql \
--set auth.postgresPassword=secretpassword \
--set primary.persistence.size=20Gi
# Override with a values file (preferred for anything non-trivial)
helm install my-release bitnami/postgresql \
--values postgres-values.yaml \
--namespace databases \
--create-namespace
# Dry run: render templates and validate without installing
helm install my-release ./mychart --dry-run --debug
# Pin a specific chart version
helm install my-release bitnami/postgresql --version 13.4.0
|
Upgrading releases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
# Upgrade an existing release
helm upgrade my-release bitnami/postgresql --values postgres-values.yaml
# The idempotent pattern: install if not present, upgrade if it is
helm upgrade --install my-release bitnami/postgresql \
--values postgres-values.yaml \
--namespace databases \
--create-namespace
# Roll back automatically if the upgrade fails
helm upgrade --install my-release ./myapp \
--values prod.values.yaml \
--atomic \
--timeout 5m0s \
--wait
|
Rollbacks
1
2
3
4
5
6
7
8
9
10
11
|
# View release history
helm history my-release
# Roll back to the previous revision
helm rollback my-release
# Roll back to a specific revision number
helm rollback my-release 3
# Roll back and wait for completion
helm rollback my-release 3 --wait
|
Inspection and debugging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
# List all releases in all namespaces
helm list --all-namespaces
# List releases in a specific namespace
helm list -n production
# Show release status
helm status my-release -n production
# Get the values currently deployed
helm get values my-release
# Get values from a specific revision
helm get values my-release --revision 2
# Get the fully rendered manifest of a release
helm get manifest my-release
# Render templates locally without contacting the cluster
helm template my-release ./mychart --values prod.values.yaml
# Lint a chart for errors
helm lint ./mychart
helm lint ./mychart --values prod.values.yaml
# Package a chart into a .tgz archive
helm package ./mychart
|
Uninstalling
1
2
3
4
5
|
# Uninstall a release (removes all resources Helm created)
helm uninstall my-release -n production
# Keep the release history after uninstall (useful for auditing)
helm uninstall my-release --keep-history
|
Quick command reference
| Command |
Purpose |
helm repo add |
Register a chart repository |
helm repo update |
Fetch latest chart index from all repos |
helm search repo <term> |
Search registered repos |
helm install |
Install a new release |
helm upgrade |
Upgrade an existing release |
helm upgrade --install |
Install or upgrade (idempotent) |
helm rollback |
Revert to a previous revision |
helm list |
List installed releases |
helm status |
Show release status and notes |
helm history |
Show all revisions of a release |
helm get values |
Show deployed values |
helm get manifest |
Show deployed manifests |
helm template |
Render templates locally |
helm lint |
Validate chart structure and templates |
helm package |
Package chart into .tgz |
helm uninstall |
Remove a release |
helm dependency update |
Download chart dependencies |
Values and Configuration
The values.yaml file
values.yaml provides the defaults for every configurable option in the chart. Every value used in a template should have a default here.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
# values.yaml
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
tag: "" # Defaults to appVersion from Chart.yaml if empty
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podSecurityContext: {}
securityContext: {}
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: "nginx"
annotations: {}
hosts:
- host: myapp.example.com
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 100m
memory: 64Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
env: []
config:
logLevel: info
maxConnections: 100
nodeSelector: {}
tolerations: []
affinity: {}
|
Overriding values
Inline with --set — best for single values in scripts or CI:
1
2
3
4
|
helm upgrade --install myapp ./myapp \
--set image.tag=1.4.2 \
--set replicaCount=3 \
--set ingress.hosts[0].host=myapp.prod.example.com
|
With a values file — best for environment-specific configuration:
1
2
|
helm upgrade --install myapp ./myapp \
--values values-prod.yaml
|
Multiple values files — files are merged left to right, later files win:
1
2
3
4
|
helm upgrade --install myapp ./myapp \
--values values-base.yaml \
--values values-prod.yaml \
--set image.tag="${GIT_SHA}"
|
Value precedence (highest wins)
--set (or --set-string, --set-file, --set-json)
└── -f / --values (last file listed wins)
└── chart defaults in values.yaml
Base + environment overlay pattern
Keep a base values file with shared configuration and separate environment overrides:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# values-base.yaml
image:
repository: ghcr.io/myorg/myapp
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
config:
logLevel: info
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
# values-prod.yaml
replicaCount: 3
image:
tag: "1.14.2"
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
hosts:
- host: myapp.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: myapp-tls
hosts:
- myapp.example.com
config:
logLevel: warn
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
|
Built-in objects
Beyond .Values, Helm provides several built-in objects accessible in every template:
| Object |
Description |
Example |
.Release.Name |
The release name |
myapp-prod |
.Release.Namespace |
The namespace being installed into |
production |
.Release.IsInstall |
True on first install |
{{ if .Release.IsInstall }} |
.Release.IsUpgrade |
True on upgrade |
|
.Chart.Name |
Chart name from Chart.yaml |
myapp |
.Chart.Version |
Chart version |
0.3.1 |
.Chart.AppVersion |
App version |
1.14.2 |
.Capabilities.KubeVersion |
Cluster Kubernetes version |
v1.29.0 |
.Files.Get "config.ini" |
Read a non-template file from the chart |
|
.Template.Name |
Current template filename |
|
Global values
Values under the global key are inherited by all subcharts, making them useful for shared configuration like storage classes or domain names:
1
2
3
4
5
|
# values.yaml (parent chart)
global:
storageClass: longhorn
domain: homelab.example.com
imageRegistry: ghcr.io
|
Subcharts access these as .Values.global.storageClass.
Helm Templating Deep Dive
Go template basics
Helm templates use Go’s text/template syntax. Actions are delimited by {{ }}. The hyphen-dash variant {{- }} and {{- -}} trims whitespace around the action, which is important for producing clean YAML output.
1
2
3
4
5
6
7
8
|
# Standard action — keeps surrounding whitespace
{{ .Values.image.repository }}
# Trim leading whitespace
{{- .Values.image.repository }}
# Trim both leading and trailing whitespace
{{- .Values.image.repository -}}
|
Conditionals
1
2
3
4
5
6
7
8
9
10
|
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
{{- if .Values.ingress.annotations }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
{{- end }}
{{- end }}
|
1
2
3
4
5
6
7
8
|
# if/else
{{- if eq .Values.service.type "LoadBalancer" }}
# LoadBalancer-specific config
{{- else if eq .Values.service.type "NodePort" }}
# NodePort-specific config
{{- else }}
# Default ClusterIP config
{{- end }}
|
Loops with range
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
spec:
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
|
Note the use of $ to access the root context inside a range loop, since . is rebound to the current iteration item.
Variables
1
2
3
4
5
|
{{- $fullName := include "myapp.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
# Use $fullName and $svcPort later in the same template
name: {{ $fullName }}
|
Functions and pipelines
Helm ships with Sprig, a library of over 70 template functions, plus several Helm-specific additions. Pipelines chain functions with |.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
# quote: wrap a value in double quotes (important for strings that look like booleans/numbers)
image: {{ .Values.image.repository | quote }}
# default: use a fallback if the value is empty
tag: {{ .Values.image.tag | default .Chart.AppVersion }}
# toYaml + nindent: the most common pattern for injecting YAML blocks
resources:
{{- toYaml .Values.resources | nindent 2 }}
# Works for any YAML block: env vars, tolerations, affinity, volumes
env:
{{- toYaml .Values.env | nindent 2 }}
tolerations:
{{- toYaml .Values.tolerations | nindent 2 }}
# upper / lower / title
name: {{ .Values.name | upper }}
# trim whitespace
label: {{ .Values.label | trim }}
# b64enc: base64 encode for Secrets
password: {{ .Values.auth.password | b64enc | quote }}
# required: fail fast if a value is not set
image: {{ required "image.repository must be set" .Values.image.repository }}
# tpl: render a value that itself contains template syntax
annotations:
description: {{ tpl .Values.description . }}
|
The _helpers.tpl file
_helpers.tpl is where you define named templates that are reused across multiple manifests. This avoids duplicating logic like label sets and resource names.
{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
Truncates at 63 characters because Kubernetes name fields have this limit.
*/}}
{{- define "myapp.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 "myapp.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels — applied to every resource for consistent selection.
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels — used in Deployment selector and Service selector.
These must be stable across upgrades; do not put version-changing values here.
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use.
*/}}
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
Named templates are called with include (preferred over template because include can be piped):
1
2
3
4
|
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
|
Building a Real Helm Chart from Scratch
Let’s build a complete chart for a web application with a configurable Deployment, Service, Ingress with TLS, ConfigMap, and optional HPA.
Scaffold the chart
1
2
|
helm create myapp
cd myapp
|
Chart.yaml
1
2
3
4
5
6
|
apiVersion: v2
name: myapp
description: A production-ready chart for a containerized web application
type: application
version: 0.1.0
appVersion: "1.0.0"
|
values.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
|
replicaCount: 1
image:
repository: nginx
pullPolicy: IfNotPresent
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
service:
type: ClusterIP
port: 80
targetPort: 8080
ingress:
enabled: false
className: "nginx"
annotations: {}
hosts:
- host: myapp.example.com
paths:
- path: /
pathType: Prefix
tls: []
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 5
periodSeconds: 5
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
# Extra environment variables passed to the container
env: []
# - name: DATABASE_URL
# value: "postgres://..."
# - name: SECRET_KEY
# valueFrom:
# secretKeyRef:
# name: myapp-secret
# key: secret-key
# Application configuration rendered into a ConfigMap
config:
logLevel: info
maxConnections: 100
featureFlags: ""
# Database credentials rendered into a Secret
auth:
password: ""
apiKey: ""
nodeSelector: {}
tolerations: []
affinity: {}
# Volumes and volume mounts for additional storage
extraVolumes: []
extraVolumeMounts: []
|
templates/deployment.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
|
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
# Force a rollout when the ConfigMap changes by hashing its contents
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "myapp.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: {{ include "myapp.fullname" . }}-config
key: logLevel
- name: MAX_CONNECTIONS
valueFrom:
configMapKeyRef:
name: {{ include "myapp.fullname" . }}-config
key: maxConnections
{{- if .Values.auth.password }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "myapp.fullname" . }}-secret
key: password
{{- end }}
{{- with .Values.env }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
- name: tmp
mountPath: /tmp
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: tmp
emptyDir: {}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
|
templates/service.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
apiVersion: v1
kind: Service
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "myapp.selectorLabels" . | nindent 4 }}
|
templates/ingress.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
|
templates/configmap.yaml
1
2
3
4
5
6
7
8
9
10
11
12
|
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp.fullname" . }}-config
labels:
{{- include "myapp.labels" . | nindent 4 }}
data:
logLevel: {{ .Values.config.logLevel | quote }}
maxConnections: {{ .Values.config.maxConnections | quote }}
{{- if .Values.config.featureFlags }}
featureFlags: {{ .Values.config.featureFlags | quote }}
{{- end }}
|
templates/secret.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
{{- if or .Values.auth.password .Values.auth.apiKey }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "myapp.fullname" . }}-secret
labels:
{{- include "myapp.labels" . | nindent 4 }}
type: Opaque
data:
{{- if .Values.auth.password }}
password: {{ .Values.auth.password | b64enc | quote }}
{{- end }}
{{- if .Values.auth.apiKey }}
apiKey: {{ .Values.auth.apiKey | b64enc | quote }}
{{- end }}
{{- end }}
|
templates/hpa.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "myapp.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
|
templates/NOTES.txt
{{- $fullName := include "myapp.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
Thank you for installing {{ .Chart.Name }} (chart version {{ .Chart.Version }}, app version {{ .Chart.AppVersion }}).
Release name: {{ .Release.Name }}
Namespace: {{ .Release.Namespace }}
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
Application URL: http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
Get the application URL by running:
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ $fullName }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else }}
Get the application URL by running:
kubectl port-forward --namespace {{ .Release.Namespace }} svc/{{ $fullName }} 8080:{{ $svcPort }}
echo "Visit http://127.0.0.1:8080"
{{- end }}
To check the status of your deployment:
helm status {{ .Release.Name }} -n {{ .Release.Namespace }}
kubectl get pods -n {{ .Release.Namespace }} -l "app.kubernetes.io/instance={{ .Release.Name }}"
Testing your chart
1
2
3
4
5
6
7
8
9
10
11
12
|
# Render templates locally and review output
helm template myapp-test ./myapp --values values-prod.yaml
# Lint for errors and warnings
helm lint ./myapp
helm lint ./myapp --values values-prod.yaml
# Dry run against a live cluster (validates against the API server)
helm install myapp-test ./myapp --dry-run --debug --values values-prod.yaml
# Install for real
helm install myapp-test ./myapp --values values-prod.yaml -n myapp --create-namespace
|
Chart Dependencies
Declaring dependencies
Add a dependencies block to Chart.yaml:
1
2
3
4
5
6
7
8
9
10
11
|
dependencies:
- name: postgresql
version: "13.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
- name: redis
version: "18.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
alias: cache # Install as "cache" instead of "redis"
|
Then download the dependencies:
1
2
3
4
5
6
7
8
9
10
|
# Add the required repos first
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
# Download dependencies into charts/
helm dependency update ./myapp
# Now the charts/ directory contains:
# charts/postgresql-13.4.0.tgz
# charts/redis-18.2.0.tgz
|
Controlling which subcharts are installed
The condition field references a value path. If that value is false, the subchart is not installed:
1
2
3
4
5
6
7
8
|
# values.yaml
postgresql:
enabled: true
redis:
enabled: false
auth:
enabled: false
|
Passing values to subcharts
Use the subchart name (or alias) as the top-level key in your parent values.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
# values.yaml — parent chart
postgresql:
enabled: true
auth:
postgresPassword: "changeme"
database: "myappdb"
primary:
persistence:
enabled: true
size: 10Gi
storageClass: longhorn
cache: # This is the alias for redis
enabled: true
auth:
enabled: false
master:
persistence:
enabled: false
|
The parent chart’s values are merged into the subchart’s values under its key. You can inspect what the subchart will receive with helm get values after install or by rendering templates locally.
Alias: same chart installed twice
1
2
3
4
5
6
7
8
9
10
11
12
|
dependencies:
- name: postgresql
version: "13.x.x"
repository: "https://charts.bitnami.com/bitnami"
alias: primarydb
condition: primarydb.enabled
- name: postgresql
version: "13.x.x"
repository: "https://charts.bitnami.com/bitnami"
alias: readdb
condition: readdb.enabled
|
Each alias creates a separate release of the postgresql chart with its own resources, configured independently via primarydb: and readdb: keys in values.
Managing Releases in Production
Release naming conventions
A consistent naming convention prevents confusion when you have many releases across many namespaces:
prod-myapp, staging-myapp — environment prefix
- Or use namespaces to separate environments and keep release names simple:
myapp in namespace production, myapp in namespace staging
The namespace approach is cleaner for GitOps workflows and makes RBAC easier to manage.
The idempotent upgrade pattern
In CI/CD, always use helm upgrade --install. This command installs the chart if the release does not exist, or upgrades it if it does. It is safe to run on every deployment without checking state first:
1
2
3
4
5
6
7
8
9
|
helm upgrade --install myapp ./myapp \
--namespace production \
--create-namespace \
--values values-base.yaml \
--values values-prod.yaml \
--set image.tag="${DOCKER_TAG}" \
--atomic \
--wait \
--timeout 5m0s
|
--atomic is particularly important in CI: if the upgrade fails (pods crash, health checks fail within the timeout), Helm automatically rolls back to the previous working revision and the command exits with a non-zero status code, failing the pipeline.
Inspecting release history
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# View all revisions for a release
helm history myapp -n production
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 2026-03-01 09:12:03 superseded myapp-0.2.1 1.12.0 Install complete
2 2026-03-10 14:30:55 superseded myapp-0.2.2 1.13.0 Upgrade complete
3 2026-03-20 11:05:22 failed myapp-0.3.0 1.14.0 Upgrade "myapp" failed
4 2026-03-20 11:07:01 deployed myapp-0.2.2 1.13.0 Rollback to 2
# Inspect the values used in a specific revision
helm get values myapp -n production --revision 2
# Inspect the full manifest from a specific revision
helm get manifest myapp -n production --revision 2
|
The helm-diff plugin
The helm-diff plugin shows you exactly what will change before you apply an upgrade — analogous to terraform plan. Install it once:
1
|
helm plugin install https://github.com/databus23/helm-diff
|
Use it before any upgrade:
1
2
3
4
|
helm diff upgrade myapp ./myapp \
--values values-prod.yaml \
--set image.tag=1.14.2 \
-n production
|
Output shows a colored diff of every Kubernetes resource that will be added, modified, or removed. This is invaluable for catching unexpected changes (like a values file change that affects more resources than intended).
Helm Repositories: Hosting and Sharing Charts
Finding charts on Artifact Hub
Artifact Hub (artifacthub.io) is the official index for Helm charts, OCI artifacts, and other cloud-native packages. It surfaces charts from hundreds of publishers with security reports, changelogs, and default values documentation. Always check Artifact Hub before writing a chart for common infrastructure components — there is almost certainly a maintained chart already.
Hosting your own chart repo on GitHub Pages
For private or custom charts, GitHub Pages is the simplest hosting option:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
# 1. Package your chart
helm package ./mychart
# Produces: mychart-0.1.0.tgz
# 2. Generate or update the repo index
helm repo index . --url https://myorg.github.io/helm-charts/
# 3. Commit the .tgz and index.yaml to your gh-pages branch
# The index.yaml is what helm repo add fetches
# 4. Add the repo
helm repo add myorg https://myorg.github.io/helm-charts/
helm repo update
helm install myapp myorg/mychart
|
OCI registries (Helm 3.8+)
Helm 3.8 added stable OCI support, allowing you to store charts in any OCI-compatible registry (Docker Hub, GHCR, Harbor, AWS ECR):
1
2
3
4
5
6
7
8
9
10
11
|
# Log in to the registry
helm registry login ghcr.io --username myuser --password $GITHUB_TOKEN
# Push a packaged chart
helm package ./mychart
helm push mychart-0.1.0.tgz oci://ghcr.io/myorg/charts
# Pull and install from OCI
helm install myapp oci://ghcr.io/myorg/charts/mychart --version 0.1.0
# No repo add/update needed — OCI charts are referenced directly by URL
|
OCI storage is generally preferred for new setups because it reuses existing container registry infrastructure, supports fine-grained access control, and integrates with existing image signing workflows.
Chart signing
Sign charts to verify authenticity:
1
2
3
4
5
|
# Sign during package
helm package --sign --key "Jane Smith" --keyring ~/.gnupg/secring.gpg ./mychart
# Verify before install
helm install myapp ./mychart-0.1.0.tgz --verify
|
Helm in CI/CD and GitOps
GitHub Actions workflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Helm
uses: azure/setup-helm@v4
with:
version: "3.14.0"
- name: Configure kubectl
uses: azure/k8s-set-context@v4
with:
method: kubeconfig
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Lint chart
run: helm lint ./charts/myapp --values ./charts/myapp/values-prod.yaml
- name: Render templates (sanity check)
run: |
helm template myapp ./charts/myapp \
--values ./charts/myapp/values-prod.yaml \
--set image.tag=${{ github.sha }} \
> /tmp/rendered.yaml
kubectl apply --dry-run=client -f /tmp/rendered.yaml
- name: Deploy to production
run: |
helm upgrade --install myapp ./charts/myapp \
--namespace production \
--create-namespace \
--values ./charts/myapp/values-prod.yaml \
--set image.tag=${{ github.sha }} \
--atomic \
--wait \
--timeout 5m0s
|
Helm with Flux
Flux’s HelmRelease CRD manages Helm releases declaratively. Declare the desired state in git; Flux reconciles continuously:
1
2
3
4
5
6
7
8
9
|
# flux/helmrepository-bitnami.yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: bitnami
namespace: flux-system
spec:
interval: 24h
url: https://charts.bitnami.com/bitnami
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
# flux/helmrelease-postgresql.yaml
apiVersion: helm.toolkit.fluxcd.io/v2beta2
kind: HelmRelease
metadata:
name: postgresql
namespace: databases
spec:
interval: 30m
chart:
spec:
chart: postgresql
version: "13.x.x"
sourceRef:
kind: HelmRepository
name: bitnami
namespace: flux-system
values:
auth:
postgresPassword: "${DB_PASSWORD}" # Substituted from a Secret
primary:
persistence:
enabled: true
storageClass: longhorn
size: 20Gi
valuesFrom:
- kind: Secret
name: postgresql-values-secret
valuesKey: values.yaml
|
Helm with ArgoCD
ArgoCD manages Helm releases as Applications:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: myapp
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/myorg/myapp
targetRevision: main
path: charts/myapp
helm:
valueFiles:
- values-prod.yaml
parameters:
- name: image.tag
value: "1.14.2"
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
|
Helmfile: declarative multi-release management
Helmfile lets you declare all your Helm releases in a single file and manage them together. It is particularly useful when you have a large homelab stack with many releases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
# helmfile.yaml
repositories:
- name: ingress-nginx
url: https://kubernetes.github.io/ingress-nginx
- name: cert-manager
url: https://charts.jetstack.io
- name: prometheus-community
url: https://prometheus-community.github.io/helm-charts
- name: bitnami
url: https://charts.bitnami.com/bitnami
environments:
production:
values:
- environments/production.yaml
staging:
values:
- environments/staging.yaml
releases:
- name: ingress-nginx
namespace: ingress-nginx
createNamespace: true
chart: ingress-nginx/ingress-nginx
version: "4.10.0"
values:
- releases/ingress-nginx/values.yaml
- name: cert-manager
namespace: cert-manager
createNamespace: true
chart: cert-manager/cert-manager
version: "1.14.0"
values:
- releases/cert-manager/values.yaml
set:
- name: installCRDs
value: true
- name: kube-prometheus-stack
namespace: monitoring
createNamespace: true
chart: prometheus-community/kube-prometheus-stack
version: "57.x.x"
values:
- releases/kube-prometheus-stack/values.yaml
- name: myapp
namespace: production
createNamespace: true
chart: ./charts/myapp
values:
- releases/myapp/values.yaml
- releases/myapp/values-{{ .Environment.Name }}.yaml
set:
- name: image.tag
value: "{{ requiredEnv \"IMAGE_TAG\" }}"
|
1
2
3
4
5
6
7
8
9
|
# Common Helmfile commands
helmfile deps # Update chart dependencies
helmfile diff # Preview changes across all releases
helmfile sync # Apply all releases
helmfile apply # Diff then sync (asks for confirmation)
helmfile destroy # Uninstall all releases
# Target a specific release
helmfile --selector name=myapp sync
|
The Helm ecosystem has mature, well-maintained charts for virtually everything you would want to self-host. Here is a curated list of the most useful charts for a homelab or small production cluster:
| Category |
Repository |
Chart |
What it deploys |
| Ingress |
ingress-nginx |
ingress-nginx/ingress-nginx |
NGINX Ingress Controller |
| TLS |
cert-manager |
cert-manager/cert-manager |
cert-manager (Let’s Encrypt automation) |
| Storage |
longhorn |
longhorn/longhorn |
Longhorn distributed block storage |
| Monitoring |
prometheus-community |
kube-prometheus-stack |
Prometheus, Alertmanager, Grafana |
| Logging |
grafana |
grafana/loki-stack |
Loki + Promtail log aggregation |
| Tracing |
jaegertracing |
jaegertracing/jaeger |
Jaeger distributed tracing |
| GitOps |
argo |
argo/argo-cd |
Argo CD |
| GitOps |
fluxcd-community |
fluxcd-community/flux2 |
Flux v2 |
| Backups |
vmware-tanzu |
vmware-tanzu/velero |
Velero cluster backup |
| Databases |
bitnami |
bitnami/postgresql |
PostgreSQL |
| Databases |
bitnami |
bitnami/mysql |
MySQL |
| Databases |
bitnami |
bitnami/redis |
Redis |
| Files |
nextcloud |
nextcloud/nextcloud |
Nextcloud |
| Media |
k8s-at-home |
jellyfin |
Jellyfin media server |
| Home Auto. |
home-assistant |
pajikos/home-assistant |
Home Assistant |
| Dashboard |
kubernetes-dashboard |
kubernetes-dashboard/kubernetes-dashboard |
Kubernetes Dashboard |
| Registry |
twuni |
twuni/docker-registry |
Private Docker registry |
Setting up a homelab stack
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
# Add all necessary repos
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo add cert-manager https://charts.jetstack.io
helm repo add longhorn https://charts.longhorn.io
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update
# Install ingress controller
helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx --create-namespace \
--set controller.service.type=LoadBalancer
# Install cert-manager with CRDs
helm upgrade --install cert-manager cert-manager/cert-manager \
--namespace cert-manager --create-namespace \
--set installCRDs=true
# Install Longhorn storage
helm upgrade --install longhorn longhorn/longhorn \
--namespace longhorn-system --create-namespace \
--set defaultSettings.defaultDataPath=/var/lib/longhorn
# Install Prometheus stack
helm upgrade --install kube-prometheus-stack \
prometheus-community/kube-prometheus-stack \
--namespace monitoring --create-namespace \
--values monitoring-values.yaml
# Install Argo CD
helm upgrade --install argocd argo/argo-cd \
--namespace argocd --create-namespace \
--values argocd-values.yaml
|
Values file management for a homelab stack
The cleanest way to manage a homelab Helm stack in git is one values file per release:
helm-values/
├── ingress-nginx/
│ └── values.yaml
├── cert-manager/
│ └── values.yaml
├── longhorn/
│ └── values.yaml
├── kube-prometheus-stack/
│ └── values.yaml
├── argocd/
│ └── values.yaml
└── myapp/
├── values.yaml # shared defaults
├── values-prod.yaml # production overrides
└── values-staging.yaml
Keep this directory in the same git repository as your Helmfile or your Flux/ArgoCD definitions. Every change to a values file goes through a pull request, giving you a full audit trail and the ability to revert any configuration change. Combine this with helm diff or Flux’s diff planning and you have a workflow with the auditability of Terraform applied to Kubernetes deployments.
Summary
Helm is not optional for anyone running more than a handful of Kubernetes applications. Raw YAML works for getting started, but it does not scale — not across environments, not for sharing, and not for confident upgrades and rollbacks. Helm’s chart model solves the parameterization problem cleanly: templates define structure, values define configuration, and releases track history.
The key practices to internalize:
- Always use
helm upgrade --install in CI/CD for idempotency
- Use
--atomic --wait --timeout in pipelines to catch failures early
- Use
helm diff before upgrading production releases
- Keep one values file per release per environment in git
- For a homelab, use Helmfile to manage all releases declaratively
- For GitOps at scale, use Flux
HelmRelease or ArgoCD Application resources instead of running Helm directly
The community chart ecosystem is mature enough that you will rarely need to write charts for infrastructure components. Write charts for your own applications, and consume existing charts for everything else. The combination of good chart hygiene, values-in-git, and automated CI deployment gives you production-grade Kubernetes operations without requiring a dedicated platform team.
Comments