Building Consistent Helm Charts Your Team Will Love
Creating a Helm chart is easy. Creating a Helm chart that feels familiar, is easy to customize, and doesn't surprise users—that's harder. Here's your practical guide.
The Problem: Inconsistent Charts
Every team eventually builds multiple Helm charts. Maybe one for each microservice, or one per environment, or dozens for different customer deployments. Without standards, each chart becomes a unique snowflake:
- One uses
imageTag, another usesimage.tag - Ingress is enabled by default in some, disabled in others
- Resource limits are in different formats across charts
- ServiceAccount naming is unpredictable
This creates cognitive load. Every chart requires reading documentation. Copy-pasting values from one chart to another doesn't work. New team members struggle.
The solution: establish standards and templates.
Step 1: Start With a values.yaml Template
Based on analyzing 1,589 charts, here's a starter template that covers 80% of use cases:
# values.yaml
# Default values for [CHART_NAME]
# -- Number of replicas
replicaCount: 1
image:
# -- Container image repository
repository: nginx
# -- Image pull policy
pullPolicy: IfNotPresent
# -- Overrides the image tag (default is the chart appVersion)
tag: ""
# -- Image pull secrets
imagePullSecrets: []
# -- Override the chart name
nameOverride: ""
# -- Override the full generated name
fullnameOverride: ""
serviceAccount:
# -- Specifies whether a service account should be created
create: true
# -- Annotations to add to the service account
annotations: {}
# -- The name of the service account to use (if not set, a name is generated)
name: ""
# -- Annotations to add to pods
podAnnotations: {}
# -- Pod security context
podSecurityContext: {}
# fsGroup: 2000
# -- Container security context
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
# -- Service type
type: ClusterIP
# -- Service port
port: 80
ingress:
# -- Enable ingress
enabled: false
# -- Ingress class name
className: ""
# -- Ingress annotations
annotations: {}
# kubernetes.io/ingress.class: nginx
# cert-manager.io/cluster-issuer: letsencrypt-prod
# -- Ingress hosts configuration
hosts:
- host: chart-example.local
paths:
- path: /
pathType: Prefix
# -- Ingress TLS configuration
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources:
# -- Resource limits
# limits:
# cpu: 100m
# memory: 128Mi
# -- Resource requests
# requests:
# cpu: 100m
# memory: 128Mi
autoscaling:
# -- Enable horizontal pod autoscaling
enabled: false
# -- Minimum number of replicas
minReplicas: 1
# -- Maximum number of replicas
maxReplicas: 10
# -- Target CPU utilization percentage
targetCPUUtilizationPercentage: 80
# targetMemoryUtilizationPercentage: 80
# -- Node selector for pod assignment
nodeSelector: {}
# -- Tolerations for pod assignment
tolerations: []
# -- Affinity for pod assignment
affinity: {}Step 2: Add Consistent Helpers
Create _helpers.tpl with standard template functions every chart should have:
{{/*
Expand the name of the chart.
*/}}
{{- define "mychart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "mychart.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 "mychart.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "mychart.labels" -}}
helm.sh/chart: {{ include "mychart.chart" . }}
{{ include "mychart.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "mychart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "mychart.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "mychart.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}Replace mychart with your actual chart name in all templates.
Step 3: Build Deployment Template
The deployment template should reference all values consistently:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "mychart.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
{{- toYaml .Values.podAnnotations | nindent 8 }}
labels:
{{- include "mychart.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "mychart.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: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}Step 4: Standardize Service and Ingress
Keep service.yaml simple and predictable:
apiVersion: v1
kind: Service
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "mychart.selectorLabels" . | nindent 4 }}And ingress.yaml with proper conditionals:
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mychart.fullname" . }}
labels:
{{- include "mychart.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 "mychart.fullname" $ }}
port:
number: {{ $.Values.service.port }}
{{- end }}
{{- end }}
{{- end }}Step 5: Document Everything
Create a comprehensive README.md. At minimum, include:
- What the chart deploys
- Prerequisites (Kubernetes version, dependencies)
- Installation instructions with examples
- Configuration table listing all values
- Common customization examples
Consider using helm-docs to auto-generate documentation from comments in values.yaml.
Step 6: Create Team Standards Document
Beyond individual charts, create a team-wide standards doc covering:
Chart Structure
- Required files: Chart.yaml, values.yaml, README.md, templates/_helpers.tpl
- Optional files: NOTES.txt, .helmignore
- Template organization (one file per Kubernetes resource type)
Naming Conventions
- Use nested configuration (see our naming conventions guide)
- Always include nameOverride and fullnameOverride
- Match Kubernetes field names exactly
- Use .enabled for toggles, .create for resource creation
Default Values
- One replica by default
- ClusterIP service by default
- Ingress disabled by default
- Autoscaling disabled by default
- ServiceAccount created by default
- No resource limits set (but examples commented out)
Security Practices
- Never commit secrets to values.yaml
- Always create a ServiceAccount (don't use default)
- Include securityContext examples
- Use specific image tags, never
latest
Step 7: Automate Testing
Add CI/CD checks for your charts:
# .github/workflows/lint.yaml
name: Lint Charts
on:
pull_request:
paths:
- 'charts/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: azure/setup-helm@v3
- run: helm lint charts/*
- name: Install chart-testing
uses: helm/chart-testing-action@v2
- name: Run chart-testing
run: ct lint --all --config .ct.yamlStep 8: Version Consistently
Follow semantic versioning for chart versions:
- Patch (0.0.x): Bug fixes, documentation updates
- Minor (0.x.0): New features, backward-compatible changes
- Major (x.0.0): Breaking changes, removed values, changed defaults
Document breaking changes in Chart.yaml annotations:
apiVersion: v2
name: mychart
version: 2.0.0
appVersion: 1.23.0
annotations:
artifacthub.io/changes: |
- kind: breaking
description: Changed default service type from LoadBalancer to ClusterIP
- kind: added
description: Added support for HorizontalPodAutoscalerStep 9: Common Customizations
Document the most common customization patterns your team uses:
# Development environment (minimal resources)
helm install myapp ./mychart \
--set replicaCount=1 \
--set resources.requests.cpu=50m \
--set resources.requests.memory=64Mi
# Production environment (high availability)
helm install myapp ./mychart \
--set replicaCount=3 \
--set autoscaling.enabled=true \
--set autoscaling.minReplicas=3 \
--set autoscaling.maxReplicas=10 \
--set ingress.enabled=true \
--set ingress.hosts[0].host=myapp.example.com
# Custom image from private registry
helm install myapp ./mychart \
--set image.repository=registry.company.com/myapp \
--set image.tag=v1.2.3 \
--set imagePullSecrets[0].name=registry-credentialsStep 10: Review Checklist
Before releasing a chart, verify:
- ✅ All values documented in README
- ✅ Follows team naming conventions
- ✅ Includes standard helpers (_helpers.tpl)
- ✅ Safe defaults (ClusterIP, ingress disabled, etc.)
- ✅ ServiceAccount created by default
- ✅ nameOverride and fullnameOverride included
- ✅ Passes
helm lint - ✅ Can be installed with zero customization
- ✅ Common customizations tested
- ✅ Version bumped appropriately
Real-World Example
Here's how a mature team structures their charts:
charts/
├── _templates/ # Shared template for new charts
│ ├── Chart.yaml
│ ├── values.yaml
│ └── templates/
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ └── serviceaccount.yaml
├── api-service/
│ └── ... (follows template structure)
├── worker-service/
│ └── ... (follows template structure)
└── STANDARDS.md # Team conventions documentNew charts are created by copying _templates/ and customizing only what's needed.
The Payoff
Teams that follow these practices report:
- New developers can understand any chart in minutes, not hours
- Copying configuration between charts "just works"
- Fewer surprises during deployments
- Easier code reviews (just check diff against template)
- Faster onboarding for new team members
Next Steps
Start with one chart. Apply these standards. Use it as the template for the next one. Gradually migrate existing charts during normal maintenance.
Need inspiration? Use our search tool to see how popular charts structure their values, then adapt what works for your team.