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:
versionis the Chart’s own version, incremented on every template or values changeappVersionis the packaged application’s version, independent of the Chart versiondependenciesdeclares dependent sub-charts;conditioncontrols 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:
| Object | Description | Example |
|---|---|---|
.Values | Values from values.yaml | .Values.image.repository |
.Release.Name | Release name | my-release |
.Release.Namespace | Target namespace | production |
.Release.IsInstall | Whether first install | true / false |
.Release.IsUpgrade | Whether an upgrade operation | true / false |
.Chart.Name | Chart name | my-web-app |
.Chart.AppVersion | Application version | 2.4.1 |
.Files | Access 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:
| Hook | Trigger Timing | Typical Use |
|---|---|---|
pre-install | After template rendering, before resource creation | Create external dependencies (e.g., databases) |
post-install | After all resources are created | Database migration, data initialization |
pre-upgrade | After upgrade rendering, before replacement | Data backup |
post-upgrade | After upgrade completes | Cache warming, health checks |
pre-delete | Before deletion | Clean up external resources |
post-delete | After deletion | Cleanup 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:
- Clear directory structure—
Chart.yamlfor metadata,values.yamlfor configuration,templates/for templates, each with its own responsibility - Powerful template syntax—
{{ }}expressions + Sprig functions +range/if/withcontrol structures meet 99% of configuration needs - Layered values override—
--set>-f> defaultvalues.yaml; multi-environment configuration through file separation without modifying templates - Named template reuse—define common snippets like labels and names in
_helpers.tpl, referenced uniformly by all templates for consistency - Flexible hooks—
pre-install/post-upgradeand other hooks support initialization or migration at lifecycle key points - Standardized private repository management—Harbor unifies image and Chart hosting; OCI protocol makes
helm pushsimpler - CI/CD automation—
helm lint → package → pushfully 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:
- official Helm documentation — Helm Project, referenced for official Helm documentation