- Helm installs CRDs from
crds/on first install only - Helm never upgrades CRDs (to prevent accidental data loss)
- Helm never deletes CRDs (they may be used by other releases)
- For CRD upgrades: manage separately with
kubectl applyor a dedicated CRD chart - Check CRD availability in templates:
{{ if .Capabilities.APIVersions.Has "mygroup.io/v1" }}
Creating Custom Charts
Scaffold, develop, lint, package, and publish your own Helm charts — from scratch to production-ready.
🧒 Simple Explanation (ELI5)
So far you've been installing charts that other people built. Now it's your turn to bake the recipe. Creating a custom chart means writing the templates, setting smart defaults in values.yaml, and packaging it so anyone on your team can install your app with one command.
🔧 Technical Explanation
Scaffolding a New Chart
# Create scaffold
helm create mywebapp
# Generated structure:
mywebapp/
├── .helmignore # Files to skip when packaging
├── Chart.yaml # Chart metadata
├── values.yaml # Default configuration
├── charts/ # Dependencies
└── templates/
├── _helpers.tpl # Named template definitions
├── deployment.yaml
├── hpa.yaml # Horizontal Pod Autoscaler
├── ingress.yaml
├── service.yaml
├── serviceaccount.yaml
├── NOTES.txt # Post-install message
└── tests/
└── test-connection.yaml
Key Files Deep Dive
Chart.yaml — Metadata
apiVersion: v2
name: mywebapp
description: A production-ready web application chart
type: application # "application" (installable) or "library" (helpers only)
version: 0.1.0 # Chart version (bump on chart changes)
appVersion: "1.16.0" # Version of the app being deployed
keywords:
- web
- nodejs
maintainers:
- name: DevOps Team
email: devops@company.com
values.yaml — Smart Defaults
replicaCount: 1
image:
repository: myregistry/mywebapp
pullPolicy: IfNotPresent
tag: "" # Defaults to appVersion
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
autoscaling:
enabled: false
minReplicas: 1
maxReplicas: 10
targetCPUUtilizationPercentage: 80
_helpers.tpl — Reusable Snippets
{{/*
Expand the name of the chart.
*/}}
{{- define "mywebapp.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "mywebapp.labels" -}}
helm.sh/chart: {{ include "mywebapp.chart" . }}
{{ include "mywebapp.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "mywebapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mywebapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
NOTES.txt — Post-Install Message
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mywebapp.fullname" . }})
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "ClusterIP" .Values.service.type }}
kubectl port-forward svc/{{ include "mywebapp.fullname" . }} {{ .Values.service.port }}:{{ .Values.service.port }}
{{- end }}
Linting & Validating
# Lint for errors helm lint ./mywebapp helm lint ./mywebapp -f custom-values.yaml # Render templates locally (no cluster needed) helm template myrelease ./mywebapp # Render specific template helm template myrelease ./mywebapp -s templates/deployment.yaml # Dry-run against cluster (validates with K8s API) helm install myrelease ./mywebapp --dry-run --debug
Packaging & Publishing
# Package into .tgz helm package ./mywebapp # Output: mywebapp-0.1.0.tgz # Push to OCI registry (Helm 3.8+) helm push mywebapp-0.1.0.tgz oci://myregistry.azurecr.io/helm # Or host a classic chart repository: # 1. Generate index.yaml helm repo index . --url https://charts.example.com # 2. Serve index.yaml + .tgz via web server or GitHub Pages
Chart Best Practices
| Practice | Why |
|---|---|
| Use named templates for labels | Consistency across all resources |
Pin image tag via appVersion | Image version tracks chart version |
Set required for critical values | Fail fast with clear error messages |
Use resources: in templates | Prevent unbounded resource usage |
| Include NOTES.txt | Guides users after install |
Add .helmignore | Exclude tests, docs, .git from package |
| Bump version on every change | Immutable chart versions for reproducibility |
Chart Signing & Verification
For production charts, sign packages to verify integrity and authenticity:
# Sign a chart package with a GPG key helm package --sign --key "DevOps Team" --keyring ~/.gnupg/keyring.gpg ./mywebapp # Creates: mywebapp-0.1.0.tgz + mywebapp-0.1.0.tgz.prov (provenance file) # Verify a signed chart helm verify mywebapp-0.1.0.tgz # "Signed by: DevOps Team" → Trusted # Install with verification helm install myapp mywebapp-0.1.0.tgz --verify --keyring ~/.gnupg/keyring.gpg # Note: OCI registries support cosign for image-style signing: # cosign sign myregistry.azurecr.io/helm/mywebapp:0.1.0 # cosign verify myregistry.azurecr.io/helm/mywebapp:0.1.0
Handling CRDs in Charts
myoperator/ ├── crds/ │ └── myresource-crd.yaml # Installed BEFORE templates on first install ├── templates/ │ ├── myresource-instance.yaml # Custom resource using the CRD │ └── deployment.yaml └── Chart.yaml
⌨️ Hands-on
# Lab: Create and deploy a custom chart
# Step 1: Scaffold
helm create demoweb
cd demoweb
# Step 2: Customize values.yaml
# Change image to nginx:alpine, set replicaCount: 2
# Step 3: Add a ConfigMap template
cat > templates/configmap.yaml <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "demoweb.fullname" . }}-config
labels:
{{- include "demoweb.labels" . | nindent 4 }}
data:
APP_ENV: {{ .Values.appEnv | default "development" | quote }}
APP_NAME: {{ .Chart.Name | quote }}
EOF
# Step 4: Lint
helm lint .
# Step 5: Render and inspect
helm template demo . | less
# Step 6: Install
helm install demo . -n demo --create-namespace
# Step 7: Verify
kubectl get all -n demo
helm get manifest demo -n demo
# Step 8: Package
cd ..
helm package demoweb/
ls *.tgz
# Cleanup
helm uninstall demo -n demo
🐛 Debugging Scenarios
Scenario 1: "Error: YAML parse error on templates/deployment.yaml"
# Usually a whitespace/indentation issue in Go templates
# Step 1: Render the template to see raw output
helm template test . -s templates/deployment.yaml 2>&1
# Step 2: Use --debug for more detail
helm template test . --debug
# Common causes:
# - Missing {{- (dash) causing unwanted newlines
# - nindent with wrong number
# - Unclosed {{ if }} blocks
Scenario 2: "rendered manifests contain a resource that already exists"
# Another release or manual kubectl create owns that resource # Options: # 1. Use a unique fullname (check _helpers.tpl) # 2. Adopt existing resource: kubectl annotate configmap existing-cm \ meta.helm.sh/release-name=myrelease \ meta.helm.sh/release-namespace=default kubectl label configmap existing-cm \ app.kubernetes.io/managed-by=Helm
Scenario 3: helm lint passes but install fails
# helm lint doesn't talk to the cluster, so: # - Invalid apiVersion won't be caught # - Missing CRDs won't be caught # - Resource quotas won't be checked # Use --dry-run to validate against the cluster API helm install test . --dry-run --debug # Also run kubeconform or kubeval for schema validation helm template test . | kubeconform -strict
🎯 Interview Questions
Beginner
A scaffold with Chart.yaml, values.yaml, templates/ (deployment, service, ingress, HPA, serviceaccount, _helpers.tpl, NOTES.txt, tests/), charts/ directory, and .helmignore. It generates a working Nginx chart out of the box.
version is the chart version — bump it when you change the chart (templates, defaults). appVersion is the version of the application the chart deploys (e.g., nginx 1.25.0). They are independent. version follows semver. appVersion is informational and often used as a default image tag.
A file containing named template definitions (using define). Used to centralize common snippets like labels, names, and selectors so they're consistent across all templates. It starts with an underscore so Helm doesn't try to render it as a manifest. Included via {{ include "name" . }}.
A template file that Helm renders and displays after install/upgrade. It provides usage instructions — how to access the deployed application (port-forward, ingress URL, etc.). It's templated, so it can show context-specific instructions based on values.
helm template <name> <chart> renders locally without a cluster. helm install --dry-run renders and validates against the cluster API but doesn't create resources. Use -s templates/deployment.yaml to render a single template.
Intermediate
application charts are installable — they have templates that produce manifests. library charts only contain helper templates (define blocks) and cannot be installed directly. Library charts are used as dependencies to share template logic across multiple application charts.
helm package ./mychart → helm push mychart-1.0.0.tgz oci://registry.example.com/charts. Login first with helm registry login. OCI support is GA in Helm 3.8+. Consumers install with helm install myrelease oci://registry.example.com/charts/mychart --version 1.0.0.
Pipeline: helm template test . | kubeconform -strict -kubernetes-version 1.28.0. Also helm lint for template syntax, helm install --dry-run for cluster-side validation, and ct lint (chart-testing) for comprehensive CI checks.
Every time you change anything in the chart: templates, values.yaml, Chart.yaml, _helpers.tpl. Chart versions should be immutable — once v1.2.3 is published, it should never change. Use semver: patch for fixes, minor for features, major for breaking changes.
Like .gitignore but for helm package. Lists files to exclude from the .tgz: .git/, *.md, .vscode/, test fixtures, CI configs. Keeps the packaged chart small and free of unnecessary files.
Scenario-Based
Use per-environment values files: values-dev.yaml, values-staging.yaml, values-prod.yaml. Keep minimal defaults in values.yaml. At deploy time: helm install myapp . -f values-prod.yaml. Template conditionally checks .Values.resources and applies them to the container spec.
In the crds/ directory at the chart root. Helm installs CRDs before templates, but never upgrades or deletes them (to prevent data loss). For lifecycle management, use a separate CRD chart or the operator pattern. Check if CRD exists with .Capabilities.APIVersions.Has.
Use the required function: {{ required "image.tag is required" .Values.image.tag }}. If the value is empty or not set, helm install fails with your custom error message. Better to fail early than deploy a broken release.
Annotate and label all existing resources: meta.helm.sh/release-name: myrelease, meta.helm.sh/release-namespace: mynamespace, app.kubernetes.io/managed-by: Helm. Then helm install myrelease ./mychart (or helm upgrade --install) will adopt them.
Create a library chart with shared templates. Each microservice chart lists it as a dependency. Templates use {{ include "baselib.deployment" . }} etc. Publish the library chart to an internal repo. Version it independently. Teams consume it like any other dependency.
🌍 Real-World Use Case
A SaaS company creates a chart for their Node.js API:
- Scaffold with
helm create, remove unused templates (HPA, Ingress initially) - Custom ConfigMap & Secret templates for environment config
requiredon database URL and API key values- Separate values files per environment (dev/staging/prod)
- Published to private OCI registry (ACR), consumed by ArgoCD
- Chart versioning automated with CI: version bump on every merge to main
📝 Summary
helm createscaffolds a complete chart with sensible defaults_helpers.tplcentralizes labels, names, and selectors- Lint → Template → Dry-run → Install: the validation pipeline
helm package+helm pushfor distribution- Chart versions are immutable; bump on every change