Creating Helm Charts from Scratch
Building custom Helm charts transforms repetitive Kubernetes deployments into reusable, configurable packages. Rather than maintaining separate YAML files for each environment, a well-crafted chart uses templates and values to generate the exact manifests needed for any deployment context. This approach reduces errors, accelerates deployments, and standardizes application packaging across teams.
Creating a New Chart
Helm provides a command to generate the basic chart structure:
# Create a new chart named "myapp"
helm create myapp
# View the generated structure
tree myapp
Generated Structure:
myapp/
├── .helmignore # Files to ignore when packaging
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration values
├── charts/ # Dependent charts (initially empty)
└── templates/ # Kubernetes manifest templates
├── deployment.yaml
├── hpa.yaml
├── ingress.yaml
├── service.yaml
├── serviceaccount.yaml
├── _helpers.tpl # Template helper functions
├── NOTES.txt # Post-installation notes
└── tests/
└── test-connection.yaml
This scaffolding provides a production-ready starting point with common Kubernetes resources already templated.
Chart.yaml - Defining Chart Metadata
The Chart.yaml file contains essential information about the chart:
apiVersion: v2
name: myapp
description: A production-ready Helm chart for MyApp API
type: application
version: 1.0.0 # Chart version (SemVer)
appVersion: "2.3.1" # Application version being deployed
# Optional but recommended fields
keywords:
- api
- web
- backend
home: https://github.com/company/myapp
sources:
- https://github.com/company/myapp
maintainers:
- name: Platform Team
email: platform@company.com
url: https://platform.company.com
# Icon for chart repositories
icon: https://company.com/assets/myapp-icon.png
# Kubernetes version requirements
kubeVersion: ">=1.24.0-0"
# Dependencies on other charts
dependencies:
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: postgresql.enabled
- name: redis
version: "17.x.x"
repository: "https://charts.bitnami.com/bitnami"
condition: redis.enabled
Version Management:
graph LR
A[Chart Versioning] --> B[Chart Version
1.0.0]
A --> C[App Version
2.3.1]
B --> B1[Increments when
templates change]
B --> B2[SemVer format
MAJOR.MINOR.PATCH]
C --> C1[Application version
being deployed]
C --> C2[Used as default
image tag]
- Chart version: Incremented when the chart itself changes (templates, values structure)
- App version: The version of the application being deployed (used as default image tag)
values.yaml - Configuration Schema
The values file defines all configurable parameters with sensible defaults:
# Number of replicas (overridden by autoscaling)
replicaCount: 2
# Container image configuration
image:
repository: company/myapp
pullPolicy: IfNotPresent
tag: "" # Overrides appVersion from Chart.yaml
# Image pull secrets for private registries
imagePullSecrets:
- name: regcred
# Service account configuration
serviceAccount:
create: true
annotations: {}
name: ""
# Pod annotations
podAnnotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
# Pod security context
podSecurityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 1000
# Container security context
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
# Service configuration
service:
type: ClusterIP
port: 80
targetPort: 8080
annotations: {}
# Ingress configuration
ingress:
enabled: false
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/rate-limit: "100"
hosts:
- host: api.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: myapp-tls
hosts:
- api.example.com
# Resource limits and requests
resources:
limits:
cpu: 1000m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
# Horizontal Pod Autoscaler
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 75
targetMemoryUtilizationPercentage: 80
# Node selector for pod placement
nodeSelector: {}
# Tolerations for pod scheduling
tolerations: []
# Affinity rules
affinity: {}
# Environment variables
env:
- name: LOG_LEVEL
value: "info"
- name: PORT
value: "8080"
# Environment variables from secrets/configmaps
envFrom:
- configMapRef:
name: myapp-config
- secretRef:
name: myapp-secrets
# Liveness probe
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# Readiness probe
readinessProbe:
httpGet:
path: /ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Volumes
volumes: []
# - name: cache
# emptyDir: {}
# Volume mounts
volumeMounts: []
# - name: cache
# mountPath: /app/cache
# PostgreSQL dependency configuration
postgresql:
enabled: true
auth:
database: myappdb
username: myappuser
existingSecret: myapp-postgresql-secret
primary:
persistence:
enabled: true
size: 10Gi
storageClass: "fast-ssd"
# Redis dependency configuration
redis:
enabled: true
auth:
existingSecret: myapp-redis-secret
master:
persistence:
enabled: true
size: 5Gi
Organizing Values:
graph TB
A[values.yaml Structure] --> B[Application Settings]
A --> C[Infrastructure Settings]
A --> D[Dependencies]
B --> B1[Image config]
B --> B2[Environment vars]
B --> B3[Application ports]
C --> C1[Resources]
C --> C2[Scaling]
C --> C3[Security]
C --> C4[Networking]
D --> D1[Database config]
D --> D2[Cache config]
D --> D3[Message queue config]
Creating Templates
Templates use Go templating to generate Kubernetes manifests dynamically.
Deployment Template
templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "myapp.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "myapp.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "myapp.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
{{- with .Values.env }}
env:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.envFrom }}
envFrom:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.livenessProbe }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
{{- end }}
{{- if .Values.readinessProbe }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.volumes }}
volumes:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
Key Template Features:
- Conditional logic:
{{- if not .Values.autoscaling.enabled }} - Include helpers:
{{ include "myapp.fullname" . }} - Default values:
{{ .Values.image.tag | default .Chart.AppVersion }} - YAML indentation:
{{- toYaml .Values.resources | nindent 12 }} - Config checksums: Forces pod restart when config changes
Service Template
templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "myapp.selectorLabels" . | nindent 4 }}
Ingress Template
templates/ingress.yaml:
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "myapp.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}
Ingress Template Highlights:
- Entire resource wrapped in conditional check
- Nested loops for multiple hosts and paths
- Reference to parent context with
$
Template Helpers
The _helpers.tpl file contains reusable template functions:
templates/_helpers.tpl:
{{/*
Expand the name of the chart.
*/}}
{{- define "myapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "myapp.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 }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "myapp.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
{{ include "myapp.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "myapp.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "myapp.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Generate database connection string
*/}}
{{- define "myapp.databaseUrl" -}}
{{- if .Values.postgresql.enabled }}
{{- printf "postgresql://%s:%s@%s-postgresql:5432/%s" .Values.postgresql.auth.username "<password>" (include "myapp.fullname" .) .Values.postgresql.auth.database }}
{{- else }}
{{- .Values.externalDatabase.url }}
{{- end }}
{{- end }}
Helper Functions Benefits:
graph LR
A[Template Helpers] --> B[Consistency]
A --> C[Reusability]
A --> D[Maintainability]
B --> B1[Same naming everywhere]
B --> B2[Same labels everywhere]
C --> C1[Define once, use many]
C --> C2[Reduce duplication]
D --> D1[Single source of truth]
D --> D2[Easy updates]
ConfigMap and Secret Templates
templates/configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "myapp.fullname" . }}-config
labels:
{{- include "myapp.labels" . | nindent 4 }}
data:
app.conf: |
server {
port = {{ .Values.service.targetPort }}
log_level = {{ .Values.env | first | pluck "value" | first | default "info" }}
}
database {
host = "{{ include "myapp.fullname" . }}-postgresql"
port = 5432
name = "{{ .Values.postgresql.auth.database }}"
}
{{- range $key, $value := .Values.configData }}
{{ $key }}: {{ $value | quote }}
{{- end }}
templates/secret.yaml:
{{- if .Values.secrets }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "myapp.fullname" . }}-secrets
labels:
{{- include "myapp.labels" . | nindent 4 }}
type: Opaque
data:
{{- range $key, $value := .Values.secrets }}
{{ $key }}: {{ $value | b64enc | quote }}
{{- end }}
{{- end }}
HorizontalPodAutoscaler Template
templates/hpa.yaml:
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "myapp.fullname" . }}
labels:
{{- include "myapp.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "myapp.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
{{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
{{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}
{{- end }}
NOTES.txt - Post-Installation Instructions
templates/NOTES.txt:
Thank you for installing {{ .Chart.Name }}!
Your release is named {{ .Release.Name }}.
To get the application URL:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ (index $host.paths 0).path }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "myapp.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
Watch the status with: kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "myapp.fullname" . }}
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "myapp.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "myapp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
To check the status of the deployment:
kubectl --namespace {{ .Release.Namespace }} get pods -l "app.kubernetes.io/instance={{ .Release.Name }}"
{{- if .Values.postgresql.enabled }}
Database connection information:
Host: {{ include "myapp.fullname" . }}-postgresql
Port: 5432
Database: {{ .Values.postgresql.auth.database }}
Username: {{ .Values.postgresql.auth.username }}
Password: Stored in secret {{ .Values.postgresql.auth.existingSecret }}
{{- end }}
Testing and Validation
Before deploying, validate the chart:
Lint the Chart:
# Check for errors and best practice violations
helm lint ./myapp
# Fix any warnings or errors reported
Dry Run:
# Generate manifests without installing
helm install myapp ./myapp --dry-run --debug
# Test with custom values
helm install myapp ./myapp --dry-run --debug -f production-values.yaml
# Save rendered manifests to file
helm template myapp ./myapp > rendered-manifests.yaml
Template Testing:
# Test specific template rendering
helm template myapp ./myapp --show-only templates/deployment.yaml
# Test with different values
helm template myapp ./myapp --set replicaCount=5 --show-only templates/deployment.yaml
Packaging and Distribution
Package the Chart:
# Create a versioned package
helm package ./myapp
# Output: myapp-1.0.0.tgz
# Package with updated dependencies
helm dependency update ./myapp
helm package ./myapp
Create Chart Repository:
# Create directory for chart repository
mkdir chart-repo
mv myapp-1.0.0.tgz chart-repo/
# Generate repository index
helm repo index chart-repo/ --url https://charts.company.com
# The index.yaml file contains chart metadata:
cat chart-repo/index.yaml
Host Chart Repository:
# Simple nginx configuration to serve charts
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
nginx.conf: |
server {
listen 80;
server_name charts.company.com;
root /usr/share/nginx/html;
location / {
autoindex on;
}
location ~ \.tgz$ {
add_header Content-Type application/gzip;
}
location /index.yaml {
add_header Content-Type text/yaml;
}
}
Practical Example - Multi-Tier Application
Create a chart for a complete application stack:
Chart.yaml:
apiVersion: v2
name: webapp-stack
description: Full-stack web application with API and database
type: application
version: 1.0.0
appVersion: "1.0.0"
dependencies:
- name: postgresql
version: "12.x.x"
repository: "https://charts.bitnami.com/bitnami"
- name: redis
version: "17.x.x"
repository: "https://charts.bitnami.com/bitnami"
values.yaml:
# Frontend configuration
frontend:
enabled: true
replicaCount: 3
image:
repository: company/webapp-frontend
tag: "1.0.0"
service:
type: ClusterIP
port: 80
ingress:
enabled: true
hosts:
- host: app.company.com
paths:
- path: /
pathType: Prefix
# Backend API configuration
backend:
enabled: true
replicaCount: 3
image:
repository: company/webapp-api
tag: "1.0.0"
service:
type: ClusterIP
port: 8080
ingress:
enabled: true
hosts:
- host: api.company.com
paths:
- path: /
pathType: Prefix
# Database configuration
postgresql:
enabled: true
auth:
database: webappdb
username: webappuser
existingSecret: webapp-postgresql-secret
# Cache configuration
redis:
enabled: true
auth:
existingSecret: webapp-redis-secret
templates/backend-deployment.yaml:
{{- if .Values.backend.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "webapp-stack.fullname" . }}-backend
labels:
{{- include "webapp-stack.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
spec:
replicas: {{ .Values.backend.replicaCount }}
selector:
matchLabels:
{{- include "webapp-stack.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: backend
template:
metadata:
labels:
{{- include "webapp-stack.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: backend
spec:
containers:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
ports:
- containerPort: {{ .Values.backend.service.port }}
env:
- name: DATABASE_URL
value: "postgresql://{{ .Values.postgresql.auth.username }}@{{ include "webapp-stack.fullname" . }}-postgresql:5432/{{ .Values.postgresql.auth.database }}"
- name: REDIS_URL
value: "redis://{{ include "webapp-stack.fullname" . }}-redis-master:6379"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.auth.existingSecret }}
key: password
{{- end }}
Install the Stack:
# Update dependencies
helm dependency update ./webapp-stack
# Install with all components
helm install mywebapp ./webapp-stack
# Install with frontend disabled
helm install mywebapp ./webapp-stack --set frontend.enabled=false
Common Pitfalls
Hardcoded Values: Embedding values directly in templates makes charts inflexible. Use .Values references instead.
Missing Conditional Checks: Forgetting to wrap optional resources in conditionals causes errors when features are disabled.
Improper Indentation: YAML is whitespace-sensitive. Use nindent function consistently to avoid indentation errors.
Overcomplicating Templates: Excessive logic in templates makes them hard to understand. Move complex logic to helper functions.
Not Testing with Different Values: Charts tested only with defaults may fail with custom configurations. Test multiple value combinations.
Ignoring Dependencies: Forgetting to run helm dependency update after adding dependencies results in missing subcharts.
Key Takeaways
- Use
helm createto generate a production-ready chart structure with common Kubernetes resources - Chart.yaml defines metadata including chart version, app version, and dependencies
- values.yaml establishes all configurable parameters with sensible defaults organized by category
- Templates use Go templating syntax to generate Kubernetes manifests dynamically from values
- Helper functions in _helpers.tpl promote consistency and reduce duplication across templates
- Conditional logic with
{{- if }}enables optional resources controlled by values - The
nindentfunction handles YAML indentation correctly when including multi-line values - Config checksums in deployment annotations force pod restarts when ConfigMaps change
- NOTES.txt provides post-installation instructions customized based on deployment configuration
- Always validate charts with
helm lintandhelm install --dry-run --debugbefore deploying - Package charts with
helm packageand distribute via chart repositories with index.yaml - Dependencies in Chart.yaml automatically pull in subcharts like databases and caching layers
- Keep templates simple and readable - move complex logic to helper functions
- Test charts with multiple value combinations to ensure flexibility across environments