Helm Charts: Best Practices for Kubernetes Packaging

Helm is the standard package manager for Kubernetes. Charts bundle all the Kubernetes manifests needed to deploy an application. A well-structured chart makes deployments repeatable, configurable, and

Introduction#

Helm is the standard package manager for Kubernetes. Charts bundle all the Kubernetes manifests needed to deploy an application. A well-structured chart makes deployments repeatable, configurable, and maintainable. Poorly structured charts become maintenance nightmares with hundreds of hardcoded values.

Chart Structure#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
my-app/
├── Chart.yaml           # chart metadata and dependencies
├── values.yaml          # default values
├── values-staging.yaml  # staging overrides
├── values-prod.yaml     # production overrides
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── hpa.yaml
│   ├── serviceaccount.yaml
│   ├── _helpers.tpl     # named templates
│   └── NOTES.txt        # post-install instructions
└── charts/              # subchart dependencies

Chart.yaml#

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v2
name: my-app
description: API service for the platform
type: application
version: 1.5.2        # chart version (semver)
appVersion: "2.3.1"   # application version (informational)
dependencies:
- name: postgresql
  version: "14.3.0"
  repository: "https://charts.bitnami.com/bitnami"
  condition: postgresql.enabled

values.yaml: Sensible Defaults#

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
# values.yaml
replicaCount: 2

image:
  repository: my-registry.example.com/my-app
  pullPolicy: IfNotPresent
  tag: ""  # overridden by CI with the build tag

serviceAccount:
  create: true
  annotations: {}
  name: ""

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: false
  className: nginx
  annotations: {}
  hosts: []
  tls: []

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 15

readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  periodSeconds: 10

env: {}          # additional environment variables
envFrom: []      # ConfigMap/Secret references

_helpers.tpl: Reusable Templates#

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
{{/* Generate the full name of the release */}}
{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/* Common labels */}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ include "my-app.chart" . }}
{{ include "my-app.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
{{- end }}

{{/* Selector labels */}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-app.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Deployment Template#

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
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      serviceAccountName: {{ include "my-app.serviceAccountName" . }}
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - containerPort: {{ .Values.service.targetPort }}
        livenessProbe:
          {{- toYaml .Values.livenessProbe | nindent 10 }}
        readinessProbe:
          {{- toYaml .Values.readinessProbe | nindent 10 }}
        resources:
          {{- toYaml .Values.resources | nindent 10 }}
        {{- if .Values.env }}
        env:
          {{- range $key, $value := .Values.env }}
          - name: {{ $key }}
            value: {{ $value | quote }}
          {{- end }}
        {{- end }}
        {{- if .Values.envFrom }}
        envFrom:
          {{- toYaml .Values.envFrom | nindent 10 }}
        {{- end }}

Deploying and Upgrading#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Install from local chart
helm install my-app ./my-app \
  --namespace production \
  --create-namespace \
  -f values-prod.yaml \
  --set image.tag=2.3.1

# Upgrade (or install if not present)
helm upgrade --install my-app ./my-app \
  --namespace production \
  -f values-prod.yaml \
  --set image.tag=$NEW_TAG \
  --wait \          # wait for rollout to complete
  --timeout 5m \
  --atomic          # rollback automatically if upgrade fails

# View deployed values
helm get values my-app -n production

# Rollback
helm rollback my-app 2 -n production  # rollback to revision 2

# View release history
helm history my-app -n production

Testing Charts#

1
2
3
4
5
6
7
8
9
10
11
12
# Lint: check for syntax errors and best practices
helm lint ./my-app -f values-prod.yaml

# Dry run: render templates without deploying
helm install my-app ./my-app --dry-run --debug \
  -f values-prod.yaml \
  --set image.tag=test \
  | grep -v "^#"

# Unit test templates (helm-unittest plugin)
helm plugin install https://github.com/helm-unittest/helm-unittest
helm unittest ./my-app
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# tests/deployment_test.yaml
suite: deployment tests
templates:
  - deployment.yaml
tests:
- it: should set replicas from values
  set:
    replicaCount: 3
  asserts:
  - equal:
      path: spec.replicas
      value: 3

- it: should not set replicas when autoscaling enabled
  set:
    autoscaling.enabled: true
  asserts:
  - notExists:
      path: spec.replicas

Conclusion#

A good Helm chart parameterizes everything that changes between environments, uses _helpers.tpl for DRY labels and names, and includes sensible defaults in values.yaml. Use helm upgrade --install --atomic in CI for automatic rollback on failure. Test templates with helm lint and helm-unittest before merging. Version charts independently from application versions.

Contents