LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Helm Charts: Packaging, Templating, and Managing Kubernetes Applications

helmkubernetesdevopsk3shomelabchartstemplatinggitopsdeployment
Contents

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

Useful Community Charts for Homelabs

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