Why Helm?

Managing K8s applications with bare kubectl apply -f works at small scale, but as environments multiply (dev/staging/prod) and services grow, problems quickly surface:

  • Hardcoded configuration: Each environment has its own YAML file with image tags, replica counts, and resource limits hardcoded. Changing a single value means editing ten files.
  • No version management: Upgrades and rollbacks rely on manual records—no way to know what version was last deployed.
  • No reusability: Deploying Redis and MySQL requires two completely different YAML sets with no way to templatize.

Helm is the package manager for K8s. It packages a set of K8s resources into a Chart, parameterizes configuration via values.yaml, and enables one-template multi-environment deployment, versioned upgrades, and one-click rollbacks.

This article references the official Helm documentation

Helm Chart Directory Structure

my-web-app/
├── Chart.yaml              # Chart metadata (name, version, description)
├── values.yaml             # Default configuration values
├── values-prod.yaml        # Production environment overrides
├── charts/                 # Dependent sub-charts
├── templates/              # K8s resource templates
│   ├── _helpers.tpl        # Named templates (reusable template snippets)
│   ├── deployment.yaml     # Deployment
│   ├── service.yaml        # Service
│   ├── ingress.yaml        # Ingress
│   ├── configmap.yaml      # ConfigMap
│   ├── secret.yaml         # Secret
│   ├── hpa.yaml            # HorizontalPodAutoscaler
│   ├── serviceaccount.yaml # ServiceAccount
│   └── NOTES.txt           # Post-install notes
└── .helmignore             # Helm packaging ignore file

Chart.yaml Explained

apiVersion: v2                    # Helm 3 uses v2
name: my-web-app                  # Chart name
description: A production-grade web application Helm chart
type: application                 # application | library
version: 1.2.0                    # Chart version (SemVer)
appVersion: "2.4.1"               # Application version (SemVer not enforced)
keywords:
  - web
  - api
  - microservice
maintainers:
  - name: Xu Baojin
    email: xubaojin@example.com
dependencies:
  - name: redis
    version: 18.x.x
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled      # Only install when redis.enabled=true in values

Key fields:

  • version is the Chart’s own version, incremented on every template or values change
  • appVersion is the packaged application’s version, independent of the Chart version
  • dependencies declares dependent sub-charts; condition controls whether they’re enabled

Template Syntax

Basic Expressions

# {{ .Values.xxx }} — Reference values from values.yaml
# {{ .Release.xxx }} — Reference Helm Release information
# {{ .Chart.xxx }} — Reference values from Chart.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}-{{ .Chart.Name }}
  labels:
    app.kubernetes.io/name: {{ .Chart.Name }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}

Common built-in objects:

ObjectDescriptionExample
.ValuesValues from values.yaml.Values.image.repository
.Release.NameRelease namemy-release
.Release.NamespaceTarget namespaceproduction
.Release.IsInstallWhether first installtrue / false
.Release.IsUpgradeWhether an upgrade operationtrue / false
.Chart.NameChart namemy-web-app
.Chart.AppVersionApplication version2.4.1
.FilesAccess files within the Chart.Files.Get "config/nginx.conf"

Pipes and Functions

Helm templates support pipes and the Sprig function library:

# Pipe: pass the output of one function as input to the next
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

# Common functions
{{ .Values.replicaCount | int }}        # Convert to integer
{{ .Values.image.tag | quote }}          # Add quotes
{{ .Values.hostname | upper }}           # Convert to uppercase
{{ .Values.host | replace "." "-" }}     # Replace string
{{ include "my-app.fullname" . }}        # Call a named template
{{ .Values.port | toString }}            # Convert to string

# default function: use default value when empty
imagePullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }}

# nindent function: indent N spaces (note: nindent, not indent—nindent adds a leading newline)
{{- include "my-app.labels" . | nindent 4 }}

range Loop

# values.yaml
service:
  ports:
    - name: http
      port: 80
      targetPort: 8080
    - name: metrics
      port: 9090
      targetPort: 9090

# templates/service.yaml
spec:
  type: {{ .Values.service.type }}
  ports:
    {{- range .Values.service.ports }}
    - name: {{ .name }}
      port: {{ .port }}
      targetPort: {{ .targetPort }}
      protocol: TCP
    {{- end }}

if Conditional

# values.yaml
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix

# templates/ingress.yaml
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "my-app.fullname" . }}
spec:
  ingressClassName: {{ .Values.ingress.className }}
  rules:
    {{- range .Values.ingress.hosts }}
    - host: {{ .host | quote }}
      http:
        paths:
          {{- range .paths }}
          - path: {{ .path }}
            pathType: {{ .pathType }}
            backend:
              service:
                name: {{ include "my-app.fullname" $ }}
                port:
                  number: 80
          {{- end }}
    {{- end }}
{{- end }}

Note the {{- }} syntax in templates—the hyphens strip leading/trailing whitespace, preventing extra blank lines that could cause YAML formatting errors.

with Scope

# values.yaml
livenessProbe:
  httpGet:
    path: /healthz
    port: http
  initialDelaySeconds: 10
  periodSeconds: 10

# templates/deployment.yaml
# with changes the scope of ., avoiding repeated long paths
{{- with .Values.livenessProbe }}
livenessProbe:
  {{- toYaml . | nindent 2 }}
{{- end }}

Inside a with block, . refers to .Values.livenessProbe. To access the root context within a with block, use $ (an alias for the root context).

values.yaml Layer Override Mechanism

Helm’s configuration override follows a clear priority order, from highest to lowest:

--set parameters (highest priority)
    ↓ overrides
-f / --values specified files
    ↓ overrides
Parent Chart's values.yaml (lowest)
    ↓ overridden by
Child Chart's values.yaml (lowest of the lowest)

–set Parameters

# Set a single value
helm install my-app ./my-web-app \
  --set image.tag=v2.4.1

# Set nested values
helm install my-app ./my-web-app \
  --set image.tag=v2.4.1 \
  --set replicaCount=3

# Set array elements
helm install my-app ./my-web-app \
  --set ingress.hosts[0].host=api.example.com

# Set boolean values
helm install my-app ./my-web-app \
  --set ingress.enabled=true

-f Multi-Environment Configuration Files

# values.yaml (default values)
replicaCount: 2
image:
  repository: my-web-app
  tag: latest
resources:
  requests:
    cpu: 100m
    memory: 128Mi
ingress:
  enabled: false

# values-prod.yaml (production overrides)
replicaCount: 5
image:
  tag: v2.4.1
resources:
  requests:
    cpu: 500m
    memory: 512Mi
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
# Production deployment: default values overridden by values-prod.yaml
helm upgrade --install my-app ./my-web-app \
  -f values.yaml \
  -f values-prod.yaml \
  -n production \
  --create-namespace

Parent-Child Chart Override

# Project structure
parent-chart/
├── Chart.yaml
├── values.yaml          # Parent Chart values can override child Chart
├── charts/
│   └── child-chart/
│       ├── Chart.yaml
│       └── values.yaml  # Child Chart's own defaults
└── templates/
# parent/values.yaml
# Use the child Chart name as the top-level key to override child Chart values
child-chart:
  replicaCount: 3
  image:
    tag: v2.4.1

In the parent Chart’s values.yaml, keys matching child Chart names override the child Chart’s values.yaml. However, child Chart templates can only access their own .Values—they cannot see the parent Chart’s global values.

Named Templates and Hooks

_helpers.tpl

Named templates are reusable template snippets defined in _helpers.tpl. Files starting with _ are not rendered as K8s resources; they’re only referenced by other templates.

{{/* templates/_helpers.tpl */}}

{{/*
Generate the full resource name: release-name-chart-name
*/}}
{{- 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 }}

{{/*
Standard labels: used uniformly across all resources
*/}}
{{- define "my-app.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
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 }}

{{/*
Selector labels: must match between Deployment/Service selectors
*/}}
{{- define "my-app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Using them in templates:

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  selector:
    matchLabels:
      {{- include "my-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}

Helm Hooks

Hooks allow operations to be executed at specific points in the Release lifecycle, such as initializing a database before installation or running migration scripts after upgrades.

# templates/post-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "my-app.fullname" . }}-migrate
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": post-install,post-upgrade      # Timing
    "helm.sh/hook-weight": "-5"                    # Weight (lower numbers execute first)
    "helm.sh/hook-delete-policy": hook-succeeded   # Delete Job after success
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["./app", "migrate"]

Common hook timings:

HookTrigger TimingTypical Use
pre-installAfter template rendering, before resource creationCreate external dependencies (e.g., databases)
post-installAfter all resources are createdDatabase migration, data initialization
pre-upgradeAfter upgrade rendering, before replacementData backup
post-upgradeAfter upgrade completesCache warming, health checks
pre-deleteBefore deletionClean up external resources
post-deleteAfter deletionCleanup verification

Private Repository Management: Harbor

Harbor Deployment

Harbor is a CNCF graduated project that supports both container image registries and Helm Chart repositories (Chartmuseum is integrated in 2.x versions).

# Download Harbor installer
wget https://github.com/goharbor/harbor/releases/download/v2.9.0/harbor-offline-installer-v2.9.0.tgz
tar xzf harbor-offline-installer-v2.9.0.tgz
cd harbor

# Generate self-signed certificate (use proper certificates in production)
mkdir -p /data/cert
openssl genrsa -out /data/cert/ca.key 4096
openssl req -x509 -new -nodes -sha512 -days 3650 \
  -subj "/CN=harbor.example.com" \
  -key /data/cert/ca.key \
  -out /data/cert/ca.crt

# Configure harbor.yml
cp harbor.yml.tmpl harbor.yml
# harbor.yml key configuration
hostname: harbor.example.com

http:
  port: 80

https:
  port: 443
  certificate: /data/cert/ca.crt
  private_key: /data/cert/ca.key

harbor_admin_password: YourStrongPassword

data_volume: /data/harbor

# Enable Chart repository service
chart:
  absolute_url: false
# Install (with Chart service)
./install.sh --with-chartmuseum

# Verify
docker ps | grep harbor
# harbor-core, harbor-db, harbor-jobservice, chartmuseum, registry, ...

Pushing Charts to Harbor

# 1. Add Harbor Helm repository (requires authentication)
helm repo add my-harbor https://harbor.example.com/chartrepo/library \
  --username admin --password YourStrongPassword

# 2. Package Chart
helm package ./my-web-app --version 1.2.0
# Successfully packaged chart and saved it to: my-web-app-1.2.0.tgz

# 3. Push to Harbor
helm push my-web-app-1.2.0.tgz my-harbor

# 4. Search and verify
helm search repo my-harbor/my-web-app
# NAME                    CHART VERSION  APP VERSION
# my-harbor/my-web-app    1.2.0          2.4.1
# Install from Harbor
helm install my-app my-harbor/my-web-app \
  --version 1.2.0 \
  -f values-prod.yaml \
  -n production

Helm Push Plugin Installation

Helm 3.8+ has built-in OCI support, allowing direct helm push to OCI-compatible registries (including Harbor).

# Helm 3.8+ OCI push
helm registry login harbor.example.com \
  --username admin --password YourStrongPassword

helm package ./my-web-app --version 1.2.0
helm push my-web-app-1.2.0.tgz oci://harbor.example.com/charts

# Pull and install
helm install my-app oci://harbor.example.com/charts/my-web-app \
  --version 1.2.0 \
  -n production

CI/CD Integration: GitHub Actions

# .github/workflows/helm-release.yml
name: Helm Chart Release

on:
  push:
    tags:
      - 'chart-v*'   # tag format: chart-v1.2.0

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Helm
        uses: azure/setup-helm@v4
        with:
          version: v3.14.0

      - name: Extract version
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/chart-v}" >> $GITHUB_OUTPUT

      - name: Lint Chart
        run: |
          helm lint ./charts/my-web-app          

      - name: Update Chart.yaml version
        run: |
          sed -i "s/^version:.*/version: ${{ steps.version.outputs.VERSION }}/" \
            ./charts/my-web-app/Chart.yaml          

      - name: Package Chart
        run: |
          mkdir -p packaged
          helm package ./charts/my-web-app \
            --version ${{ steps.version.outputs.VERSION }} \
            --destination packaged          

      - name: Login to Harbor
        run: |
          echo "${{ secrets.HARBOR_PASSWORD }}" | \
          helm registry login harbor.example.com \
            --username ${{ secrets.HARBOR_USERNAME }} --password-stdin          

      - name: Push Chart to Harbor
        run: |
          helm push packaged/my-web-app-${{ steps.version.outputs.VERSION }}.tgz \
            oci://harbor.example.com/charts          

      - name: Generate Release Notes
        run: |
          echo "## Helm Chart v${{ steps.version.outputs.VERSION }}" > release-notes.md
          echo "" >> release-notes.md
          echo "### Install" >> release-notes.md
          echo '```bash' >> release-notes.md
          echo "helm install my-app \\" >> release-notes.md
          echo "  oci://harbor.example.com/charts/my-web-app \\" >> release-notes.md
          echo "  --version ${{ steps.version.outputs.VERSION }} \\" >> release-notes.md
          echo "  -n production --create-namespace" >> release-notes.md
          echo '```' >> release-notes.md          

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          files: packaged/*.tgz
          body_path: release-notes.md
          token: ${{ secrets.GITHUB_TOKEN }}

Hands-on: A Complete Web Application Helm Chart

values.yaml

# values.yaml — Default configuration
replicaCount: 2

image:
  repository: my-web-app
  tag: ""
  pullPolicy: IfNotPresent
  pullSecrets: []

nameOverride: ""
fullnameOverride: ""

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

podAnnotations: {}
podSecurityContext:
  fsGroup: 1000

securityContext:
  runAsNonRoot: true
  runAsUser: 1000
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

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

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

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

livenessProbe:
  httpGet:
    path: /healthz
    port: http
  initialDelaySeconds: 15
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /ready
    port: http
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3

config:
  LOG_LEVEL: info
  APP_ENV: production

nodeSelector: {}
tolerations: []
affinity: {}

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:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "my-app.selectorLabels" . | nindent 8 }}
    spec:
      {{- with .Values.image.pullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "my-app.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
          envFrom:
            - configMapRef:
                name: {{ include "my-app.fullname" . }}-config
          {{- with .Values.livenessProbe }}
          livenessProbe:
            {{- toYaml . | nindent 12 }}
          {{- end }}
          {{- with .Values.readinessProbe }}
          readinessProbe:
            {{- toYaml . | nindent 12 }}
          {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

templates/configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "my-app.fullname" . }}-config
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
data:
  {{- range $key, $val := .Values.config }}
  {{ $key }}: {{ $val | quote }}
  {{- end }}

templates/hpa.yaml

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "my-app.fullname" . }}
  labels:
    {{- include "my-app.labels" . | nindent 4 }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "my-app.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}

Deployment Verification

# Render templates for preview (without actual deployment)
helm template my-app ./my-web-app -f values-prod.yaml

# Lint check
helm lint ./my-web-app -f values-prod.yaml
# ==> Linting ./my-web-app
# [INFO] Chart.yaml: icon is recommended
# 1 chart(s) linted, 0 chart(s) failed

# Dry-run deployment
helm install my-app ./my-web-app \
  -f values-prod.yaml \
  -n production \
  --create-namespace \
  --dry-run

# Production deployment
helm upgrade --install my-app ./my-web-app \
  -f values-prod.yaml \
  -n production \
  --create-namespace

# Check deployment status
helm list -n production
helm status my-app -n production

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

# Rollback to previous revision
helm rollback my-app 1 -n production

Summary

Helm Chart is the standard way to manage K8s applications. Key takeaways:

  1. Clear directory structureChart.yaml for metadata, values.yaml for configuration, templates/ for templates, each with its own responsibility
  2. Powerful template syntax{{ }} expressions + Sprig functions + range/if/with control structures meet 99% of configuration needs
  3. Layered values override--set > -f > default values.yaml; multi-environment configuration through file separation without modifying templates
  4. Named template reuse—define common snippets like labels and names in _helpers.tpl, referenced uniformly by all templates for consistency
  5. Flexible hookspre-install/post-upgrade and other hooks support initialization or migration at lifecycle key points
  6. Standardized private repository management—Harbor unifies image and Chart hosting; OCI protocol makes helm push simpler
  7. CI/CD automationhelm lint → package → push fully automated in GitHub Actions; tag-triggered releases

A good Helm Chart should enable developers to adapt to new environments by just modifying values.yaml without touching template files; templates should pass helm lint and helm template validation, never deploying malformed YAML. That’s the foundation of GitOps.

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. official Helm documentation — Helm Project, referenced for official Helm documentation