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]

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:

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:

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