← Back to Blog
GUIDE

Building Consistent Helm Charts Your Team Will Love

January 24, 202610 min read

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 uses image.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.yaml

Step 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 HorizontalPodAutoscaler

Step 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-credentials

Step 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 document

New 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.