Why Helm Over Raw Kubernetes Manifests
Kubernetes YAML is verbose and repetitive. A single application deployment typically requires a Deployment, Service, Ingress, ConfigMap, HorizontalPodAutoscaler, and PodDisruptionBudget — hundreds of lines of nearly identical YAML across environments, differing only in replicas, image tags, resource limits, and hostnames. Helm templates this YAML and manages the lifecycle: install, upgrade, rollback, and uninstall as atomic operations with a single command.
Chart Structure
my-api/
Chart.yaml # chart metadata
values.yaml # default configuration values
templates/
deployment.yaml
service.yaml
ingress.yaml
hpa.yaml
_helpers.tpl # reusable template partials
charts/ # chart dependencies
.helmignore
# Chart.yaml
apiVersion: v2
name: my-api
description: Production API service
version: 0.5.0 # chart version
appVersion: "1.0.0" # application version
dependencies:
- name: redis
version: "17.x.x"
repository: https://charts.bitnami.com/bitnami
condition: redis.enabled
Deployment Template
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "my-api.fullname" . }}
labels:
{{- include "my-api.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "my-api.selectorLabels" . | nindent 6 }}
template:
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
ports:
- containerPort: {{ .Values.service.port }}
env:
{{- range .Values.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
readinessProbe:
httpGet:
path: /health/ready
port: {{ .Values.service.port }}
initialDelaySeconds: 5
periodSeconds: 5
Values Files Per Environment
# values.yaml — defaults
replicaCount: 1
image:
repository: ghcr.io/org/my-api
tag: ""
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { cpu: 500m, memory: 512Mi }
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
# values-production.yaml — overrides
replicaCount: 3
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: 2000m, memory: 2Gi }
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
ingress:
host: api.production.com
# Deploy to staging
helm upgrade --install my-api ./my-api --namespace staging --values values-staging.yaml --set image.tag=${GIT_SHA}
# Deploy to production
helm upgrade --install my-api ./my-api --namespace production --values values-production.yaml --set image.tag=${GIT_SHA} --atomic # rollback automatically if upgrade fails
--timeout 5m
Helm Hooks for Database Migrations
Helm hooks run Jobs at specific points in the release lifecycle. Use a pre-upgrade hook to run database migrations before the new application pods start.
# templates/migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "my-api.fullname" . }}-migration
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migration
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["npm", "run", "db:migrate"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: my-api-secrets
key: database-url
Testing Charts with helm-unittest
# tests/deployment_test.yaml
suite: deployment tests
templates:
- templates/deployment.yaml
tests:
- it: should set correct replica count
set:
replicaCount: 3
asserts:
- equal:
path: spec.replicas
value: 3
- it: should use chart appVersion as default image tag
asserts:
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: ":1.0.0$"
Run chart tests in CI with helm unittest ./my-api before any deployment. Catching broken templates in CI is orders of magnitude cheaper than debugging failed Kubernetes deployments.