Skip to content

Building Helm Charts: From Templates to Production Deployments

DodaTech 5 min read

In this tutorial, you'll learn about Building Helm Charts: From Templates to Production Deployments. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Helm charts package Kubernetes resources into reusable, versioned, and parameterized bundles that can be deployed across environments with a single command and shared through chart repositories.

What You'll Learn

This tutorial covers scaffolding a chart from scratch, Go template syntax with pipelines and functions, managing subchart dependencies, writing lifecycle hooks for database migrations, testing charts, and publishing to OCI registries.

Why It Matters

Raw YAML files become unmanageable beyond a few Microservices -- every environment requires different values, every team duplicates the same patterns, and rollbacks require manual fixup. Helm charts enforce consistency, enable GitOps workflows, and reduce deployment failures.

Real-World Use

GitLab ships their entire platform as a single Helm chart with over 200 configurable values. Docker uses Helm charts in Docker Desktop to let users deploy complex stacks like WordPress with a single command, managing subcharts for MySQL and Redis automatically.

graph TD
  A[Chart.yaml] --> B[templates/]
  A --> C[values.yaml]
  A --> D[charts/ dependencies]
  B --> E[deployment.yaml]
  B --> F[service.yaml]
  B --> G[_helpers.tpl]
  B --> H[tests/]
  C --> I[Helm install -f values.yaml]
  I --> J[Rendered YAML]
  J --> K[kubectl apply]

Expected output: diagram showing chart structure -- Chart.yaml, templates, values, and dependencies feeding into the Helm Rendering Pipeline that produces Kubernetes manifests.

Chart Structure

A Helm chart follows a standard directory layout.

helm create mychart
tree mychart/

Expected output:

mychart/
  Chart.yaml
  values.yaml
  charts/
  templates/
    _helpers.tpl
    deployment.yaml
    hpa.yaml
    ingress.yaml
    service.yaml
    serviceaccount.yaml
    tests/
      test-connection.yaml

Chart.yaml

apiVersion: v2
name: webapp
description: A production-ready web application chart
type: application
version: 0.1.0
appVersion: "1.16.0"
kubeVersion: ">=1.25.0-0"
dependencies:
- name: postgresql
  version: "12.5.x"
  repository: https://charts.bitnami.com/bitnami
  condition: postgresql.enabled
- name: redis
  version: "18.x"
  repository: https://charts.bitnami.com/bitnami
  condition: redis.enabled
keywords:
- web
- api
- kubernetes
maintainers:
- name: myteam
  email: team@example.com

Template Functions and Pipelines

Helm uses Go templates with built-in functions for dynamic YAML generation.

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "webapp.fullname" . }}
  labels:
    {{- include "webapp.labels" . | nindent 4 }}
    app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "webapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "webapp.selectorLabels" . | nindent 8 }}
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        imagePullPolicy: {{ .Values.image.pullPolicy }}
        ports:
        - containerPort: {{ .Values.service.port }}
        resources:
          {{- toYaml .Values.resources | nindent 10 }}
        env:
        {{- range .Values.env }}
        - name: {{ .name }}
          value: {{ .value | quote }}
        {{- end }}
# values.yaml
replicaCount: 3
image:
  repository: myapp
  tag: ""
  pullPolicy: IfNotPresent
service:
  type: ClusterIP
  port: 8080
resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 200m
    memory: 256Mi
env:
- name: LOG_LEVEL
  value: info
- name: NODE_ENV
  value: production
ingress:
  enabled: true
  host: app.example.com
postgresql:
  enabled: true
  auth:
    database: myapp
redis:
  enabled: false

Named Templates

Define reusable blocks in _helpers.tpl and include them anywhere.

# templates/_helpers.tpl
{{- define "webapp.fullname" -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end -}}

{{- define "webapp.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}

{{- define "webapp.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
# Render templates locally without installing
helm template myrelease ./mychart

# Render with custom values
helm template myrelease ./mychart -f production-values.yaml

# Check for syntax errors
helm lint ./mychart

Expected output: helm template outputs the complete rendered YAML. helm lint reports Chart.yaml: valid and no issues found if everything is correct.

Lifecycle Hooks

Run jobs at specific points in the release lifecycle.

# templates/db-migration-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "webapp.fullname" . }}-migration
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: migration
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
        command: ["/bin/sh", "-c"]
        args: ["npm run migrate"]
        env:
        - name: DB_URL
          value: "postgresql://{{ .Release.Name }}-postgresql:5432/{{ .Values.postgresql.auth.database }}"
# Install the chart -- hooks run automatically
helm install myrelease ./mychart

# Check hook execution
kubectl get jobs
kubectl logs job/myrelease-webapp-migration

Expected output: the migration job runs after the release is installed. The job logs show database migration output like "Migration 001_create_users_table completed".

Testing Charts

Write test pods in templates/tests/ to validate deployments.

# templates/tests/test-connection.yaml
apiVersion: v1
kind: Pod
metadata:
  name: "{{ include "webapp.fullname" . }}-test"
  annotations:
    "helm.sh/hook": test
spec:
  containers:
  - name: curl
    image: curlimages/curl:latest
    command: ['sh', '-c']
    args:
    - |
      echo "Testing endpoint..."
      curl -f http://{{ include "webapp.fullname" . }}:{{ .Values.service.port }}/health
  restartPolicy: Never
# Run chart tests
helm test myrelease

# Expected output
# POD NAME                          STATUS
# myrelease-webapp-test             success

Expected output: the test pod reports success if the health endpoint returns HTTP 200.

Practice Questions

  1. What is the difference between Chart version and appVersion? Chart version tracks changes to the chart definition and templates. appVersion tracks the application version being deployed. They are independent -- you can update the chart without changing the app.

  2. How do conditionally include a subchart dependency? Use the condition field in Chart.yaml dependencies with a corresponding values.yaml entry. Setting PostgreSQL.enabled: false skips the PostgreSQL subchart during installation.

  3. What command validates a chart without installing it? helm lint checks for YAML syntax errors, missing required fields, and template syntax issues. helm template renders all templates locally for manual review.

Frequently Asked Questions

How do I manage values across multiple environments?

Create separate values files for each environment -- dev-values.yaml, staging-values.yaml, production-values.yaml. Use helm install -f for the specific environment file. You can layer multiple -f files, with later files overriding earlier ones. For secrets, use tools like Helm Secrets or Mozilla SOPS to encrypt sensitive values.

What is the best way to handle chart versioning?

Follow Semantic Versioning (semver). Increment the patch version for bug fixes in templates, minor version for new features or backward-compatible changes, and major version for breaking changes. Use Helm repositories with a versioned index to allow users to pin specific chart versions in their CI/CD pipelines.

Can Helm charts reference values from other charts?

Yes. Subchart values are accessible in the parent chart using .Values.subchartname.key. The parent chart's values override subchart defaults. Use the --set flag with dotted notation to override deeply nested values. For cross-chart references, export values explicitly using the exports field in subchart Chart.yaml.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro