Chart Scaffold
helm create
helm create generates a starter chart. It's opinionated — a lot of what it generates you'll delete — but it's the fastest way to understand the expected structure:
# Scaffold a new chart
helm create grade-api
# Resulting structure:
# grade-api/
# ├── Chart.yaml ← Chart metadata (name, version, appVersion, dependencies)
# ├── values.yaml ← Default configurable values
# ├── charts/ ← Dependency charts (sub-charts) go here
# ├── templates/ ← Go template files rendered into K8s manifests
# │ ├── deployment.yaml
# │ ├── service.yaml
# │ ├── ingress.yaml
# │ ├── serviceaccount.yaml
# │ ├── hpa.yaml
# │ ├── NOTES.txt ← Displayed after install/upgrade
# │ └── _helpers.tpl ← Named template definitions (not rendered as manifest)
# └── .helmignore ← Files to exclude from packaging
What Goes Where
templates/*.yaml: Each file is rendered through Go's text/template engine againstValues,Chart,Release, andCapabilitiesbuilt-in objects. The output is a Kubernetes manifest.templates/_*.tpl: Files starting with_are NOT rendered as manifests — they contain named template definitions (macros) that other templates call withinclude.NOTES.txt: Printed to stdout after every install/upgrade. Use it to show connection instructions, next steps, and important URLs.charts/: Dependencies declared inChart.yamlare downloaded here byhelm dependency update.
Chart.yaml Anatomy
# grade-api/Chart.yaml
apiVersion: v2 # Always v2 for Helm 3
name: grade-api
description: REST API for managing student grades with PostgreSQL backend
type: application # 'application' (deployable) or 'library' (only helpers)
version: 0.1.0 # Chart version (semver) — bump on every chart change
appVersion: "1.0.0" # Version of the app being deployed (informational)
keywords:
- api
- education
- grades
home: https://github.com/example/grade-api
sources:
- https://github.com/example/grade-api
maintainers:
- name: Platform Team
email: platform@example.com
# Dependencies: other charts this chart needs
dependencies:
- name: postgresql
version: "16.2.3" # Pin exact version
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled # Only install if values.postgresql.enabled=true
version is the chart package version — bump it whenever the chart templates change. appVersion is the application code version — it's purely informational and shown in helm list. You can change appVersion without changing version if you're just updating the default image tag in values.yaml.
values.yaml Design
Values Hierarchy (Precedence)
Values are merged in this order (later entries win):
values.yamlin the chart (defaults)-f custom-values.yamlflag (environment-specific overrides)--set key=valueflags (CLI overrides, highest precedence)
This means your chart's values.yaml should contain sensible production-safe defaults. Override only what differs per environment.
grade-api values.yaml
# grade-api/values.yaml
# Replica count — override per environment
replicaCount: 2
image:
repository: ghcr.io/example/grade-api
tag: "1.0.0" # Override with CI build tag
pullPolicy: IfNotPresent
# Service configuration
service:
type: ClusterIP
port: 80
targetPort: 8080
# Ingress — disabled by default, enable per environment
ingress:
enabled: false
className: nginx
host: grades.example.com
tls: false
# Resource requests and limits
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# Horizontal Pod Autoscaler
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
# Application configuration (becomes a ConfigMap)
config:
dbHost: "grade-db-postgresql.grade-api.svc.cluster.local"
dbPort: "5432"
dbName: "grades"
logLevel: "info"
# Secrets configuration (database password comes from external secret)
secrets:
dbPasswordSecretName: "grade-api-db-secret"
dbPasswordSecretKey: "password"
# PostgreSQL sub-chart (bitnami/postgresql)
postgresql:
enabled: true
auth:
database: grades
username: grade_user
existingSecret: "grade-api-db-secret"
secretKeys:
userPasswordKey: "password"
# Pod-level settings
podAnnotations: {}
podLabels: {}
nodeSelector: {}
tolerations: []
affinity: {}
serviceAccount:
create: true
name: ""
annotations: {}
Go Template Syntax
Actions & Pipelines
Helm templates use Go's text/template package. Expressions are wrapped in double curly braces. A pipeline chains functions with | (pipe), just like Unix shell:
# Basic value access
{{ .Values.replicaCount }} # → 2
{{ .Release.Name }} # → "grade-api-prod"
{{ .Chart.Name }} # → "grade-api"
{{ .Chart.Version }} # → "0.1.0"
# Pipelines — left result flows into next function as last argument
{{ .Values.image.tag | quote }} # → "1.0.0"
{{ .Values.image.tag | upper }} # → "1.0.0" (no change, example only)
{{ .Release.Name | trunc 63 | trimSuffix "-" }} # Safe label truncation
# Conditionals
{{- if .Values.ingress.enabled }}
# renders only if ingress.enabled = true
{{- end }}
{{- if and .Values.ingress.enabled .Values.ingress.tls }}
# renders only if BOTH are true
{{- end }}
# Default values (prevents nil errors)
{{ .Values.config.logLevel | default "info" }}
{{ .Values.podAnnotations | default dict | toYaml }}
Essential Sprig Functions
Helm includes the Sprig template function library. The most useful ones for chart authors:
# String manipulation
{{ "hello" | upper }} # → "HELLO"
{{ "hello-world" | camelcase }}# → "HelloWorld"
{{ "grade-api-prod" | trunc 63 | trimSuffix "-" }} # label-safe name
# Quoting (critical for YAML correctness)
{{ .Values.image.tag | quote }} # wraps in ""
{{ .Values.image.tag | squote }}# wraps in ''
# Type conversion
{{ .Values.service.port | toString }} # int → string
{{ "8080" | atoi }} # string → int
# YAML/JSON
{{ .Values.resources | toYaml | nindent 12 }} # marshal + indent
{{ .Values.config | toJson }} # marshal as JSON
# Crypto (for generating passwords in tests — NOT production)
{{ randAlphaNum 32 | b64enc }}
# Conditional helpers
{{ .Values.config.logLevel | default "info" | quote }}
{{ empty .Values.podAnnotations | not }} # true if annotations are set
# Date
{{ now | date "2006-01-02" }} # Go date format
Whitespace Control
{{- (trim left) and -}} (trim right) to eat whitespace, and nindent to add consistent indentation.
# Without whitespace control — creates blank lines
{{ if .Values.ingress.enabled }}
...
{{ end }}
# With whitespace control — no blank lines inserted
{{- if .Values.ingress.enabled }}
...
{{- end }}
# nindent: add newline + N spaces of indentation
# Required when embedding multi-line values
resources:
{{- toYaml .Values.resources | nindent 2 }}
# Produces:
# resources:
# requests:
# cpu: 100m
# memory: 128Mi
# limits:
# cpu: 500m
# memory: 256Mi
# indent: add N spaces but NO leading newline
labels:
{{ include "grade-api.labels" . | indent 4 }}
grade-api Deployment Template
Let's write the full Deployment template for grade-api. This is a realistic, production-quality template:
# grade-api/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "grade-api.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "grade-api.labels" . | nindent 4 }}
annotations:
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "grade-api.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "grade-api.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
serviceAccountName: {{ include "grade-api.serviceAccountName" . }}
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
env:
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: {{ include "grade-api.fullname" . }}-config
key: db.host
- name: DB_PORT
valueFrom:
configMapKeyRef:
name: {{ include "grade-api.fullname" . }}-config
key: db.port
- name: DB_NAME
valueFrom:
configMapKeyRef:
name: {{ include "grade-api.fullname" . }}-config
key: db.name
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: {{ include "grade-api.fullname" . }}-config
key: log.level
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.secrets.dbPasswordSecretName }}
key: {{ .Values.secrets.dbPasswordSecretKey }}
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
{{- toYaml .Values.resources | nindent 10 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
_helpers.tpl
Named Templates
Named templates are reusable snippets defined in _helpers.tpl. They're called with include in other templates. Every Bitnami chart and well-written chart uses this pattern heavily — write once, use everywhere:
# grade-api/templates/_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "grade-api.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields have limits.
If release name contains chart name it will be used as-is.
*/}}
{{- define "grade-api.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 }}
{{/*
Common labels — applied to all resources for observability and tooling
*/}}
{{- define "grade-api.labels" -}}
helm.sh/chart: {{ include "grade-api.chart" . }}
{{ include "grade-api.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels — used in matchLabels and pod templates
Must be IMMUTABLE after first deploy — never add to these after creating
*/}}
{{- define "grade-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "grade-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Chart label
*/}}
{{- define "grade-api.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Service account name
*/}}
{{- define "grade-api.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "grade-api.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
include vs template
# 'template' action — outputs directly, can't be piped
{{ template "grade-api.labels" . }}
# 'include' function — captures output as string, can be piped
# This is WHY you always use include + nindent, not template
{{ include "grade-api.labels" . | nindent 4 }}
# template can't do this:
{{ template "grade-api.labels" . | nindent 4 }} # SYNTAX ERROR
include instead of template. The only advantage of template is marginally less overhead, but you almost always need to pipe the result through nindent for correct YAML indentation.
ConfigMap & Secret Templates
# grade-api/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "grade-api.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "grade-api.labels" . | nindent 4 }}
data:
db.host: {{ .Values.config.dbHost | quote }}
db.port: {{ .Values.config.dbPort | quote }}
db.name: {{ .Values.config.dbName | quote }}
log.level: {{ .Values.config.logLevel | default "info" | quote }}
# grade-api/templates/serviceaccount.yaml
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "grade-api.serviceAccountName" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "grade-api.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
Conditional Resources (HPA)
Some resources should only be created when a feature flag is enabled. The HPA is a perfect example — you don't want it in dev, but you do in prod:
# grade-api/templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "grade-api.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "grade-api.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "grade-api.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
Now override it for production:
# values-prod.yaml — only override what differs from values.yaml
replicaCount: 4
image:
tag: "1.2.0"
ingress:
enabled: true
host: grades.company.com
tls: true
autoscaling:
enabled: true
minReplicas: 4
maxReplicas: 20
targetCPUUtilizationPercentage: 60
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1
memory: 512Mi
# Deploy to production with environment-specific values
helm upgrade --install grade-api ./grade-api \
--namespace production \
--create-namespace \
--values values-prod.yaml \
--wait --atomic
Lint, Debug & Test
# Lint: check for common errors (required before publishing)
helm lint ./grade-api
# Template rendering: preview generated manifests without applying
helm template grade-api ./grade-api \
--values values-prod.yaml \
--namespace production
# Debug: show template + computed values side by side
helm template grade-api ./grade-api \
--debug
# Dry run: send rendered manifests to API server for validation (no apply)
helm install grade-api ./grade-api \
--namespace production \
--dry-run
# After install: verify the chart deployed correctly
helm test grade-api -n production
# (Runs Jobs in templates/tests/ with helm.sh/hook: test annotation)
The "Wrong Indentation" Trap
The most common Helm bug is incorrect indentation when embedding multi-line values. toYaml produces correct YAML but doesn't indent it — you must add nindent N where N is the column offset in the parent document. Forgetting to add the leading newline is the second most common mistake — use nindent (adds newline + indent) not indent (adds indent only) when the value appears on its own line.
Exercises
Service template (ClusterIP, port 80 → 8080). Run helm lint, then helm template, then helm install in a test namespace. Verify all resources are created.
values-dev.yaml (1 replica, no ingress, no HPA, small resource limits) and values-prod.yaml (4 replicas, ingress enabled, HPA enabled, larger limits). Deploy both to separate namespaces. Confirm helm list -A shows two releases.
templates/NOTES.txt that prints the grade-api's access URL (use Go template to conditionally show the Ingress host if ingress is enabled, or kubectl port-forward instructions if not). Redeploy and see the output.
Key Takeaways & Next Steps
- Chart directory layout:
Chart.yaml,values.yaml,templates/— nothing else is required - Selector labels (
matchLabels) must be immutable — never change them after first deploy - Always use
includeovertemplateso you can pipe throughnindent {{- ... -}}whitespace trimming prevents blank lines that break YAML parsing- Use
toYaml | nindent Nfor embedding complex values objects - Conditional resources with
{{- if .Values.feature.enabled }}keep charts clean across environments
Next in This Track
In Part 3: Advanced Templating & Hooks, we go deeper — range loops, with blocks, chart dependencies, pre/post-install hooks (database migrations!), and testing with helm test.