DevOpsil
Helm
92%
Needs Review

Helm Chart Best Practices: Structure, Versioning, and Templating

Dev PatelDev Patel6 min read

If you've spent any real time packaging Kubernetes applications with Helm, you know the difference between a chart that ages gracefully and one that becomes a maintenance nightmare in six months. The gap usually comes down to discipline around structure, versioning, and how you write your templates. Let's walk through the patterns that actually hold up in production.

Chart Structure: Don't Wing It

The default helm create scaffold is fine as a starting point, but most teams deviate from it in messy ways. Here's the structure I settle on for most production charts:

mychart/
├── Chart.yaml
├── values.yaml
├── values-staging.yaml
├── values-production.yaml
├── charts/                  # subcharts / dependencies
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── hpa.yaml
│   ├── serviceaccount.yaml
│   ├── NOTES.txt
│   └── tests/
│       └── test-connection.yaml
└── .helmignore

The values-staging.yaml and values-production.yaml files aren't standard Helm convention — but keeping environment overrides inside the chart repo makes it easy to version them alongside the chart itself. You apply them with -f values-production.yaml at install time.

Chart.yaml Non-Negotiables

apiVersion: v2
name: myapp
description: Application serving the core API
type: application
version: 1.4.2          # chart version — bump this on every change
appVersion: "3.2.1"     # the app version — matches your container image tag
maintainers:
  - name: platform-team
    email: [email protected]
keywords:
  - api
  - backend
sources:
  - https://github.com/yourorg/myapp

version and appVersion serve different purposes. version is the chart package version — it follows semver and should change any time you modify templates, defaults, or dependencies. appVersion is just metadata describing what app version the chart was designed for. Don't conflate them.

Versioning Strategy

Follow semver strictly and automate the bumps:

Change TypeVersion BumpExample
Breaking values change, template restructureMAJOR1.x.x → 2.0.0
New optional feature, new defaultMINOR1.4.x → 1.5.0
Bug fix, doc update, minor template tweakPATCH1.4.2 → 1.4.3

The most common mistake is treating chart releases casually. Once a chart version is published to a registry, it's immutable. Don't reuse versions. Tag your git commits:

# after bumping version in Chart.yaml
git tag helm/myapp-1.4.2
git push origin helm/myapp-1.4.2

If you're using a CI pipeline (you should be), automate chart version bumping with helm-docs and chart-testing:

# install chart-testing (ct)
brew install helm/tap/chart-testing

# lint and validate
ct lint --config ct.yaml

# run integration tests against a kind cluster
ct install --config ct.yaml

Templating Patterns That Scale

Use _helpers.tpl for Everything Repetitive

{{/* Standard labels applied to all resources */}}
{{- define "myapp.labels" -}}
helm.sh/chart: {{ include "myapp.chart" . }}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/* Selector labels — stable, don't change after first deploy */}}
{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "myapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Using app.kubernetes.io/ labels consistently is important — many Kubernetes ecosystem tools (Argo CD, Kiali, kubectl) use these to group and display resources.

Conditionals: Keep Them Readable

{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ include "myapp.fullname" . }}
  {{- with .Values.ingress.annotations }}
  annotations:
    {{- toYaml . | nindent 4 }}
  {{- end }}
spec:
  {{- if .Values.ingress.className }}
  ingressClassName: {{ .Values.ingress.className }}
  {{- 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 }}

Notice the $ when you're inside a range loop and need to access the root context. This trips up a lot of people.

Values Schema Validation

Add a values.schema.json to your chart root. Helm will validate user-supplied values against it before rendering:

{
  "$schema": "http://json-schema.org/draft-07/schema",
  "properties": {
    "replicaCount": {
      "type": "integer",
      "minimum": 1,
      "description": "Number of pod replicas"
    },
    "image": {
      "type": "object",
      "required": ["repository", "tag"],
      "properties": {
        "repository": { "type": "string" },
        "tag": { "type": "string" },
        "pullPolicy": {
          "type": "string",
          "enum": ["Always", "IfNotPresent", "Never"]
        }
      }
    }
  }
}

This saves you from mysterious runtime failures when someone passes replicaCount: "three" instead of 3.

Values File Design

Good values.yaml design is as important as the templates themselves. The defaults should work out of the box for a dev environment:

replicaCount: 1

image:
  repository: myorg/myapp
  pullPolicy: IfNotPresent
  tag: ""  # defaults to Chart.appVersion if empty

serviceAccount:
  create: true
  annotations: {}
  name: ""

podAnnotations: {}
podSecurityContext: {}
securityContext: {}

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: false
  className: ""
  annotations: {}
  hosts:
    - host: chart-example.local
      paths:
        - path: /
          pathType: Prefix

resources:
  limits:
    cpu: 500m
    memory: 128Mi
  requests:
    cpu: 100m
    memory: 64Mi

autoscaling:
  enabled: false
  minReplicas: 1
  maxReplicas: 10
  targetCPUUtilizationPercentage: 80

One pattern I always follow: never use required in values.yaml for fields the user must supply at install time. Instead, use the required template function so the error is caught at render time with a clear message:

# In your deployment.yaml template
image: {{ required "image.repository is required" .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}

Linting and Testing Before You Ship

Set up a ct.yaml in your repo root for chart-testing config:

remote: origin
target-branch: main
chart-dirs:
  - charts
chart-repos:
  - bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: --timeout 600s

Run the full validation locally before pushing:

# Lint
helm lint charts/myapp --strict

# Render and inspect
helm template myapp charts/myapp -f charts/myapp/values-staging.yaml | kubectl apply --dry-run=client -f -

# Check for deprecated APIs
helm template myapp charts/myapp | kubeval --strict

# Security scan
helm template myapp charts/myapp | trivy config -

The kubectl apply --dry-run=client step is underrated — it catches schema mismatches that helm lint misses because lint only checks Helm syntax, not Kubernetes API validity.

Packaging and Publishing

Once your chart is solid:

# Package it
helm package charts/myapp

# Push to OCI registry (Helm 3.8+)
helm push myapp-1.4.2.tgz oci://ghcr.io/yourorg/charts

# Or push to a traditional chart repo
helm repo index . --url https://charts.yourorg.com
# then upload to S3/GCS/GitHub Pages

OCI-based registries are the direction Helm is moving. If you're starting fresh, use OCI — it integrates cleanly with existing container registries and avoids running a separate chart museum.

Getting chart structure and versioning right upfront is one of those investments that pays off quietly. You won't notice it when things are going well, but you'll absolutely notice the absence of discipline when you're trying to debug a production incident and your charts are a maze of undocumented overrides and undated versions.

Share:

Was this article helpful?

Dev Patel
Dev Patel

Cloud Cost Optimization Specialist

I find the money your cloud is wasting. FinOps practitioner, data-driven analyst, and the person your CFO wishes they'd hired sooner. Every dollar saved is a dollar earned.

Related Articles

Discussion