Advanced Lesson 8 of 14

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

bash
# 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

yaml
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

yaml
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

go
{{/*
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

go
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 }}
Chart Development Lifecycle
helm create
Edit templates + values
helm lint
helm template (dry render)
helm install --dry-run
helm install
helm package
Publish to repo

Linting & Validating

bash
# 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

bash
# 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

PracticeWhy
Use named templates for labelsConsistency across all resources
Pin image tag via appVersionImage version tracks chart version
Set required for critical valuesFail fast with clear error messages
Use resources: in templatesPrevent unbounded resource usage
Include NOTES.txtGuides users after install
Add .helmignoreExclude tests, docs, .git from package
Bump version on every changeImmutable chart versions for reproducibility

Chart Signing & Verification

For production charts, sign packages to verify integrity and authenticity:

bash
# 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

text
myoperator/
├── crds/
│   └── myresource-crd.yaml    # Installed BEFORE templates on first install
├── templates/
│   ├── myresource-instance.yaml  # Custom resource using the CRD
│   └── deployment.yaml
└── Chart.yaml
⚠️
CRD Lifecycle Limitations
  • 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 apply or a dedicated CRD chart
  • Check CRD availability in templates: {{ if .Capabilities.APIVersions.Has "mygroup.io/v1" }}

⌨️ Hands-on

bash
# 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"

bash
# 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"

bash
# 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

bash
# 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

Q: What does 'helm create' generate?

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.

Q: What is the difference between 'version' and 'appVersion' in Chart.yaml?

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.

Q: What is _helpers.tpl?

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" . }}.

Q: What is NOTES.txt?

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.

Q: How do you render templates without installing?

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

Q: What is the difference between 'application' and 'library' chart types?

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.

Q: How do you publish a chart to an OCI registry?

helm package ./mycharthelm 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.

Q: How would you validate chart output against Kubernetes schemas?

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.

Q: When should you bump the chart version?

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.

Q: What is .helmignore?

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

Q: You need different resource limits per environment. How?

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.

Q: Your chart needs a CRD. Where do you put it?

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.

Q: You want to ensure a required value (like image.tag) is always provided. How?

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.

Q: A chart you want to adopt was created manually with kubectl. How do you bring it under Helm management?

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.

Q: How do you share chart templates across 20 microservices without copy-paste?

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:

📝 Summary

← Back to Helm Course