DEV Community

Cover image for Helm Charts from Scratch: Package, Version, and Deploy Kubernetes Apps Like a Pro
S, Sanjay
S, Sanjay

Posted on

Helm Charts from Scratch: Package, Version, and Deploy Kubernetes Apps Like a Pro

You can deploy to Kubernetes without Helm. You write 6 YAML files, apply them manually, and track versions in your head.

Then you add a staging environment. Then production. Then you need to change the image tag in 4 files for every release. Then someone applies the wrong file to the wrong cluster.

That's when Helm starts making sense.

Helm is the package manager for Kubernetes. It lets you templatize YAML, version your releases, and roll back when things break. Here's how to use it properly — from scratch.


What Helm Actually Does

Without Helm:

kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f configmap.yaml
kubectl apply -f ingress.yaml
kubectl apply -f hpa.yaml
kubectl apply -f serviceaccount.yaml
Enter fullscreen mode Exit fullscreen mode

With Helm:

helm install my-app ./my-chart --values production.yaml
Enter fullscreen mode Exit fullscreen mode

Helm takes a collection of templatized YAML files (a "chart"), injects your values, and deploys them as a single versioned release.

Chart = Template + Values → Rendered YAML → Kubernetes Resources
Enter fullscreen mode Exit fullscreen mode

Creating Your First Chart

helm create my-app
Enter fullscreen mode Exit fullscreen mode

This generates:

my-app/
├── Chart.yaml           # Chart metadata (name, version, description)
├── values.yaml          # Default configuration values
├── charts/              # Dependencies (sub-charts)
├── templates/           # Kubernetes manifests (templatized)
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── serviceaccount.yaml
│   ├── hpa.yaml
│   ├── _helpers.tpl     # Reusable template snippets
│   ├── NOTES.txt        # Post-install message
│   └── tests/
│       └── test-connection.yaml
└── .helmignore          # Files to exclude from packaging
Enter fullscreen mode Exit fullscreen mode

Chart.yaml — Identity Card

apiVersion: v2
name: my-app
description: A payment processing microservice
type: application
version: 1.2.0        # Chart version (changes when chart changes)
appVersion: "3.4.1"   # Application version (the Docker image tag)

maintainers:
  - name: Sanjay S
    email: sanjaysundarmurthy@gmail.com
Enter fullscreen mode Exit fullscreen mode

Important distinction:

  • version = the chart packaging version. Bump this when templates or defaults change.
  • appVersion = the actual application version being deployed. This goes in your image tag.

Template Basics

values.yaml — Configuration

replicaCount: 2

image:
  repository: myregistry.azurecr.io/payment-service
  tag: "3.4.1"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8080

ingress:
  enabled: true
  className: nginx
  host: api.example.com
  tls:
    enabled: true
    secretName: api-tls

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

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilization: 70

env:
  DATABASE_URL: "postgresql://db:5432/payments"
  LOG_LEVEL: "info"
  CACHE_TTL: "300"
Enter fullscreen mode Exit fullscreen mode

templates/deployment.yaml — Templatized Manifest

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 }}
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
    spec:
      serviceAccountName: {{ include "my-app.serviceAccountName" . }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.port }}
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /health
              port: {{ .Values.service.port }}
            initialDelaySeconds: 15
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /ready
              port: {{ .Values.service.port }}
            initialDelaySeconds: 5
            periodSeconds: 5
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          env:
            {{- range $key, $value := .Values.env }}
            - name: {{ $key }}
              value: {{ $value | quote }}
            {{- end }}
Enter fullscreen mode Exit fullscreen mode

Key patterns explained:

Syntax What it does
{{ .Values.replicaCount }} Injects value from values.yaml
{{ include "my-app.fullname" . }} Calls a helper template
{{- }} Trims whitespace (the - matters)
nindent 4 Adds a newline + 4 spaces of indentation
toYaml Converts a YAML block to properly formatted YAML
range Iterates over a list or map
quote Wraps value in quotes (important for strings)

The Config Checksum Trick

annotations:
  checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
Enter fullscreen mode Exit fullscreen mode

This forces a pod restart whenever the ConfigMap changes. Without this, updating a ConfigMap doesn't restart pods — they keep using the old configuration.


_helpers.tpl — Reusable Snippets

{{- define "my-app.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}

{{- define "my-app.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
Enter fullscreen mode Exit fullscreen mode

Environment-Specific Values

Don't modify values.yaml for different environments. Create override files:

my-app/
├── values.yaml                  # Defaults
├── values-dev.yaml              # Dev overrides
├── values-staging.yaml          # Staging overrides
└── values-production.yaml       # Production overrides
Enter fullscreen mode Exit fullscreen mode

values-dev.yaml:

replicaCount: 1

image:
  tag: "latest"

ingress:
  host: api-dev.example.com

resources:
  requests:
    cpu: 50m
    memory: 64Mi
  limits:
    cpu: 200m
    memory: 128Mi

autoscaling:
  enabled: false

env:
  LOG_LEVEL: "debug"
Enter fullscreen mode Exit fullscreen mode

values-production.yaml:

replicaCount: 3

image:
  tag: "3.4.1"

ingress:
  host: api.example.com
  tls:
    enabled: true

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: "1"
    memory: 512Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 20

env:
  LOG_LEVEL: "warn"
  CACHE_TTL: "600"
Enter fullscreen mode Exit fullscreen mode

Deploy with:

# Development
helm upgrade --install my-app ./my-app -f values-dev.yaml -n dev

# Production
helm upgrade --install my-app ./my-app -f values-production.yaml -n production
Enter fullscreen mode Exit fullscreen mode

Values are merged: values-production.yaml overrides values.yaml. Any value not specified in the override file keeps its default.


Chart Dependencies

Your app needs Redis and PostgreSQL? Don't copy their YAML — depend on their charts.

Chart.yaml:

dependencies:
  - name: redis
    version: "18.6.1"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled
  - name: postgresql
    version: "13.4.3"
    repository: "https://charts.bitnami.com/bitnami"
    condition: postgresql.enabled
Enter fullscreen mode Exit fullscreen mode
# Download dependencies
helm dependency update ./my-app

# This creates:
# my-app/charts/redis-18.6.1.tgz
# my-app/charts/postgresql-13.4.3.tgz
Enter fullscreen mode Exit fullscreen mode

Configure dependencies in values.yaml:

redis:
  enabled: true
  architecture: standalone
  auth:
    enabled: true
    password: "redis-secret"

postgresql:
  enabled: true
  auth:
    postgresPassword: "pg-secret"
    database: "payments"
Enter fullscreen mode Exit fullscreen mode

Production tip: Use condition fields so you can disable bundled dependencies in production (where you'd use managed services like Azure Cache for Redis or Azure Database for PostgreSQL instead).


Helm Hooks — Lifecycle Events

Run jobs at specific points during the release lifecycle:

apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "my-app.fullname" . }}-db-migrate
  annotations:
    "helm.sh/hook": pre-upgrade,pre-install
    "helm.sh/hook-weight": "1"
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["python", "manage.py", "migrate"]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: {{ include "my-app.fullname" . }}-secrets
                  key: database-url
Enter fullscreen mode Exit fullscreen mode

Common hooks:

Hook When it runs Use case
pre-install Before any resources are created Database setup
post-install After all resources are created Smoke tests
pre-upgrade Before upgrade begins Database migrations
post-upgrade After upgrade completes Cache warming
pre-delete Before deletion Backup data

Hook weights control execution order (lower runs first). Delete policies clean up hook resources automatically.


Helm Commands Reference

# Install a release
helm install my-app ./my-app -n production

# Upgrade (or install if not exists)
helm upgrade --install my-app ./my-app -f values-prod.yaml -n production

# See what would change (dry run)
helm upgrade --install my-app ./my-app --dry-run --debug

# Render templates locally (no cluster needed)
helm template my-app ./my-app -f values-prod.yaml

# List releases
helm list -n production

# Check release history
helm history my-app -n production
REVISION  STATUS      DESCRIPTION
1         superseded  Install complete
2         superseded  Upgrade complete
3         deployed    Upgrade complete

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

# Uninstall
helm uninstall my-app -n production

# Show default values of a public chart
helm show values bitnami/redis

# Search for charts
helm search repo nginx
helm search hub prometheus
Enter fullscreen mode Exit fullscreen mode

Testing Your Chart

Lint — Catch Errors Before Deploying

helm lint ./my-app
helm lint ./my-app -f values-production.yaml
Enter fullscreen mode Exit fullscreen mode

Template — Render Without Deploying

helm template my-app ./my-app -f values-production.yaml > rendered.yaml
Enter fullscreen mode Exit fullscreen mode

Review rendered.yaml to verify the output is correct before deploying.

Test — Verify After Deploying

# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "my-app.fullname" . }}-test"
  annotations:
    "helm.sh/hook": test
spec:
  restartPolicy: Never
  containers:
    - name: curl-test
      image: curlimages/curl:8.5.0
      command: ['curl']
      args: ['{{ include "my-app.fullname" . }}:{{ .Values.service.port }}/health']
Enter fullscreen mode Exit fullscreen mode
helm test my-app -n production
Enter fullscreen mode Exit fullscreen mode

Packaging and Publishing

Package Your Chart

helm package ./my-app
# Creates: my-app-1.2.0.tgz
Enter fullscreen mode Exit fullscreen mode

Host on Azure Container Registry (ACR)

# Login to ACR
helm registry login myregistry.azurecr.io \
  --username $ACR_USERNAME \
  --password $ACR_PASSWORD

# Push chart as OCI artifact
helm push my-app-1.2.0.tgz oci://myregistry.azurecr.io/helm

# Install from ACR
helm install my-app oci://myregistry.azurecr.io/helm/my-app --version 1.2.0
Enter fullscreen mode Exit fullscreen mode

CI/CD Integration

# In your pipeline (GitHub Actions example)
- name: Helm Lint
  run: helm lint ./charts/my-app -f values-production.yaml

- name: Helm Template
  run: helm template my-app ./charts/my-app -f values-production.yaml

- name: Helm Package
  run: helm package ./charts/my-app

- name: Helm Push
  run: |
    helm registry login ${{ secrets.ACR_LOGIN_SERVER }} \
      --username ${{ secrets.ACR_USERNAME }} \
      --password ${{ secrets.ACR_PASSWORD }}
    helm push my-app-*.tgz oci://${{ secrets.ACR_LOGIN_SERVER }}/helm
Enter fullscreen mode Exit fullscreen mode

Common Mistakes to Avoid

1. Hardcoding values in templates

# Bad
image: myregistry.azurecr.io/payment-service:3.4.1

# Good
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
Enter fullscreen mode Exit fullscreen mode

2. Not quoting string values

# Bad — YAML interprets "true" as boolean
value: {{ .Values.env.ENABLE_FEATURE }}

# Good — always quote env var values
value: {{ .Values.env.ENABLE_FEATURE | quote }}
Enter fullscreen mode Exit fullscreen mode

3. Missing resource limits
Always define resources.requests and resources.limits. Without them, a single pod can consume all node resources.

4. Not using helm upgrade --install
Use helm upgrade --install instead of separate install/upgrade commands. It installs if the release doesn't exist and upgrades if it does — idempotent and CI/CD-friendly.

5. Giant monolithic charts
If your chart has 20+ templates, break it into sub-charts. Each microservice should have its own chart, with a parent "umbrella" chart if needed.


Helm isn't just templating — it's version control for your Kubernetes deployments. When a release breaks, helm rollback gets you back to a known-good state in seconds. When you need to deploy to 5 environments, value overrides keep your templates DRY.

Start with helm create, customize the templates, set up your environment-specific values files, and you'll never manually edit 6 YAML files for a deployment again.


What's your Helm workflow? Umbrella charts or per-service charts? Share your approach.

Follow me for more Kubernetes and DevOps content.

Top comments (0)