A Helm chart isn’t just a collection of YAML files; it’s a declarative definition of a Kubernetes application that can be versioned, shared, and deployed repeatedly.

Let’s see this in action. Imagine you have a simple web application. Here’s how you might structure its Helm chart:

my-webapp/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   └── _helpers.tpl
└── charts/
    └── postgresql-ha-0.10.2/  # A dependency chart
  • Chart.yaml: This is the manifest for your chart. It contains metadata like the chart’s name, version, description, and dependencies.

    apiVersion: v2
    name: my-webapp
    version: 0.1.0
    description: A simple web application with a PostgreSQL dependency
    type: application
    dependencies:
      - name: postgresql
        version: "0.10.2"
        repository: "https://charts.bitnami.com/bitnami"
    
  • values.yaml: This file defines the default configuration for your chart. Users can override these values during installation or upgrade.

    replicaCount: 1
    
    image:
      repository: nginx
      tag: stable
      pullPolicy: IfNotPresent
    
    service:
      type: ClusterIP
      port: 80
    
    ingress:
      enabled: true
      className: nginx
      hosts:
        - host: chart-example.local
          paths:
            - path: /
              pathType: ImplementationSpecific
    
    resources: {} # Kubernetes resource requests and limits
    
    postgresql:
      enabled: true
      auth:
        username: myuser
        password: changeme
        database: mydb
    
  • templates/: This directory holds the Kubernetes manifest files (YAML) that Helm will render. These files use Go templating.

    • templates/deployment.yaml: Defines the Deployment for your application.

      apiVersion: apps/v1
      kind: Deployment
      metadata:
      
        name: {{ include "my-webapp.fullname" . }}
      
        labels:
      
          {{- include "my-webapp.labels" . | nindent 4 }}
      
      spec:
      
        replicas: {{ .Values.replicaCount }}
      
        selector:
          matchLabels:
      
            {{- include "my-webapp.selectorLabels" . | nindent 6 }}
      
        template:
          metadata:
            labels:
      
              {{- include "my-webapp.selectorLabels" . | nindent 8 }}
      
          spec:
            containers:
      
              - name: {{ .Chart.Name }}
      
      
                image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
      
      
                imagePullPolicy: {{ .Values.image.pullPolicy }}
      
                ports:
                  - name: http
                    containerPort: 80
                    protocol: TCP
                resources:
      
                  {{- toYaml .Values.resources | nindent 12 }}
      
      
    • templates/service.yaml: Defines the Service for your application.

      apiVersion: v1
      kind: Service
      metadata:
      
        name: {{ include "my-webapp.fullname" . }}
      
        labels:
      
          {{- include "my-webapp.labels" . | nindent 4 }}
      
      spec:
      
        type: {{ .Values.service.type }}
      
        ports:
      
          - port: {{ .Values.service.port }}
      
            targetPort: http
            protocol: TCP
            name: http
        selector:
      
          {{- include "my-webapp.selectorLabels" . | nindent 4 }}
      
      
    • templates/_helpers.tpl: This file contains template helpers, which are reusable snippets of template code. They help keep your main templates clean and DRY (Don’t Repeat Yourself).

      
      {{/* vim: set filetype=gotpl: */}}
      
      
      {{/*
      
      Expand the name of the chart.
      */}}
      
      {{- define "my-webapp.name" -}}
      
      
      {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
      
      
      {{- end -}}
      
      
      
      {{/*
      
      Create a default fully qualified app name.
      */}}
      
      {{- define "my-webapp.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 -}}
      
      
      
      {{/*
      
      Generate labels for the chart
      */}}
      
      {{- define "my-webapp.labels" -}}
      
      
      helm.sh/chart: {{ include "my-webapp.chart" . }}
      
      
      {{ include "my-webapp.selectorLabels" . }}
      
      
      {{- if .Chart.AppVersion }}
      
      
      app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
      
      
      {{- end }}
      
      
      app.kubernetes.io/managed-by: {{ .Release.Service }}
      
      
      {{- end -}}
      
      
      
      {{/*
      
      Selector labels
      */}}
      
      {{- define "my-webapp.selectorLabels" -}}
      
      
      app.kubernetes.io/name: {{ include "my-webapp.name" . }}
      
      
      app.kubernetes.io/instance: {{ .Release.Name }}
      
      
      {{- end -}}
      
      
      
      {{/*
      
      Chart name and version
      */}}
      
      {{- define "my-webapp.chart" -}}
      
      
      {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
      
      
      {{- end -}}
      
      
  • charts/: This directory is for subcharts, which are dependencies of your main chart. Helm will fetch and install these automatically when you deploy your chart.

The real power comes from combining these elements. You can deploy this chart with default values:

helm install my-release ./my-webapp

Or override specific values:

helm install my-release ./my-webapp \
  --set replicaCount=3 \
  --set image.tag=latest \
  --set postgresql.auth.password=securepassword

This allows for a single chart definition to manage applications across different environments (development, staging, production) by simply changing configuration values.

The most surprising thing about Helm values is that they aren’t just simple key-value pairs; they form a hierarchical structure that directly maps to the Go templating engine, allowing for conditional logic, loops, and complex data manipulation within your Kubernetes manifests.

Consider the postgresql.enabled flag in values.yaml. If set to true, the postgresql-ha subchart will be deployed. If false, it won’t. This logic is often implemented within the _helpers.tpl file or directly in the templates referencing the subchart.

The fullnameOverride and nameOverride values in values.yaml are subtle but powerful. They allow you to completely control the generated Kubernetes resource names, decoupling them from the Helm release name and chart name, which is crucial for complex deployments or when integrating with existing systems.

When you deploy a chart with dependencies, Helm doesn’t just copy the subchart files. It actually downloads them into the charts/ directory (or fetches them from a chart repository) and then merges their values with your parent chart’s values. The order of precedence is important: values provided on the command line (--set) override values in values.yaml, which override values in the subchart’s values.yaml.

The true magic of Helm lies in its ability to transform a static set of YAML files into dynamic, environment-aware Kubernetes deployments through the interplay of templating and values.

The next concept you’ll likely encounter is managing complex dependencies and dealing with chart version conflicts.

Want structured learning?

Take the full Helm course →