Helm charts make it possible to deploy the same application to ten environments with slightly different configuration. They also make it possible to produce charts so complex that nobody on the team can confidently say what gets deployed. The good charts and the bad charts both technically work; the bad ones just cost more in incidents.
This post is the practices that separate maintainable Helm charts from the kind you regret writing.
Structure That Holds Up
The default helm create skeleton is fine for a starting point and bad as the end state. A real chart layout:
mychart/
├── Chart.yaml
├── values.yaml
├── values.schema.json ← validate user-supplied values
├── templates/
│ ├── _helpers.tpl ← named templates
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── ingress.yaml
│ ├── configmap.yaml
│ └── NOTES.txt ← post-install message
├── tests/
│ └── test-connection.yaml
└── README.md
Two pieces matter most: a real values.schema.json (covered below) and disciplined use of _helpers.tpl for repeated patterns.
Values.yaml Discipline
The values.yaml is the public API of the chart. Treat it that way.
# Good — flat, predictable, documented
image:
repository: myregistry.example.com/myapp
tag: "" # defaults to .Chart.AppVersion
pullPolicy: IfNotPresent
replicaCount: 3
service:
type: ClusterIP
port: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
Three rules:
- Document every value with comments. The defaults file is the documentation; do not make users read templates.
- Use sensible defaults. A chart should install with
helm installand no values overrides. - Keep nesting shallow. Two or three levels deep, never more. Deep nesting is a pain to override.
Values Schema Validation
Helm 3+ supports JSON Schema validation of values. This catches the "I typo'd replicas as replica and Helm silently used the default" class of bug.
// values.schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["image", "replicaCount"],
"properties": {
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string" },
"pullPolicy": {
"type": "string",
"enum": ["Always", "IfNotPresent", "Never"]
}
}
},
"replicaCount": { "type": "integer", "minimum": 1 }
}
}
helm install now refuses values that do not match the schema. Most teams skip this; the result is silent typo-bugs that surface in production.
Named Templates
_helpers.tpl is for templates referenced multiple times. The defaults from helm create are a good start — mychart.fullname, mychart.labels. Add to them as patterns emerge.
# _helpers.tpl
{{- define "mychart.commonLabels" -}}
app.kubernetes.io/name: {{ include "mychart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
# deployment.yaml
metadata:
labels:
{{- include "mychart.commonLabels" . | nindent 4 }}
The principle: if a snippet appears in three places, it belongs in _helpers.tpl.
Resources Belong in Values
Hard-coding resource requests and limits in the deployment template is the most common Helm anti-pattern. Different environments need different resources.
# deployment.yaml — wrong
resources:
requests:
cpu: 100m
memory: 128Mi
# Right
resources:
{{- toYaml .Values.resources | nindent 12 }}
Production overrides production-sized resources. Staging overrides smaller ones. The chart is environment-agnostic.
The same applies to replica counts, autoscaling parameters, ingress hosts, anything that varies by environment.
Image Pull Secrets and Service Accounts
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 2 }}
{{- end }}
The with block omits the field entirely if no value is supplied. Better than a default that always renders an empty list.
For service accounts:
serviceAccountName: {{ include "mychart.serviceAccountName" . }}
With a helper that picks between the auto-created service account and a user-supplied one. This pattern shows up in well-built charts; rolling your own less polished versions is wasted work.
Subchart and Dependency Discipline
When your chart depends on other charts (Postgres, Redis, etc.), declare them in Chart.yaml:
dependencies:
- name: postgresql
version: "16.x.x"
repository: oci://registry-1.docker.io/bitnamicharts
condition: postgresql.enabled
Always pin to a major version. Letting subcharts upgrade without version pinning is how production breaks on a Tuesday morning.
Subcharts with condition flags let users opt-in. Many charts ship Postgres as a default — fine for demos, bad for production where you have an external managed Postgres. The condition makes the dependency optional.
Avoid the Anti-Patterns
Values.yaml with an "any field can be a string" schema. Numbers should be numbers, booleans should be booleans. Otherwise Helm's templating quotes incorrectly.
Pulling in a giant subchart for one feature. If you only need Postgres's secret format, copy the secret template; do not pull in the whole chart.
Templating Kubernetes-internal fields. apiVersion, kind, metadata.namespace — these are usually fixed per template. Templating them rarely helps and often causes subtle bugs.
Manual indentation calculations. nindent exists. Use it.
include of templates that emit YAML without toYaml. This causes formatting issues that look like Helm bugs but are template bugs.
Library Charts
For organizations with many similar services, library charts are the right abstraction. A library chart is a chart that provides templates but does not produce its own deployment.
# Chart.yaml of the library chart
apiVersion: v2
type: library
name: common
# Service chart that uses the library
dependencies:
- name: common
version: "1.0.0"
repository: oci://yourregistry.example.com
Each service's chart is small — values + maybe a few overrides. Common patterns (deployment shape, label conventions, network policies) live in the library and are updated once.
This is how mature platform teams ship dozens of charts without each one diverging.
Testing
Helm has a built-in helm test system, but more useful is template rendering tests during CI.
helm template mychart --values examples/production.yaml > /tmp/rendered.yaml
kubectl apply --dry-run=client -f /tmp/rendered.yaml
This catches:
- Template errors at chart build time
- Invalid Kubernetes YAML output
- Schema validation failures
For more sophisticated testing, helm-unittest (a chart testing plugin) lets you write assertion-based tests for template output.
Versioning
Charts have their own version (Chart.yaml: version) and reference an application version (Chart.yaml: appVersion). The chart version is for chart changes; the app version is for the application.
# Chart.yaml
version: 0.4.2 # chart version — bump when chart changes
appVersion: "1.7.0" # app version — bump when app changes
Treat the chart version like any other semver: major bump for breaking changes to values or behavior, minor for additions, patch for fixes. Users pinning a chart version expect this contract.
Distribution
For private charts, OCI registries are the dominant pattern in 2026.
helm push mychart-0.4.2.tgz oci://yourregistry.example.com/charts
Pulls work the same way kubectl pulls images. ECR, Harbor, GitLab Container Registry, and GitHub Container Registry all support OCI charts.
Avoid the older HTTP-based chart repositories (the helm repo add pattern). They work but are less convenient than OCI for a registry you already operate.
When Not to Use Helm
Helm is right for parameterized templates. It is less right for:
- One-off deployments with no parameterization (just write the YAML)
- Workflows where the values come from another system (Kustomize might fit better)
- Configurations where strict typing matters more than templating (Pulumi, CDK)
The choice between Helm and Kustomize comes up often. Helm is better for distributing parameterized applications (an internal team chart used by many consumers). Kustomize is better for managing environment-specific overrides of a fixed base.
The Practical Outcome
A maintainable chart:
- Documented values with sane defaults
- JSON schema validation of values
- Common patterns extracted to helpers
- Resources, replicas, and host names parameterized
- Library charts for repeated patterns
- Versioned distribution via OCI registry
The discipline pays off when the chart is consumed by ten teams across five environments. The cost shows up if you skip it and try to retrofit later.
Reviewing a Helm chart that has grown past the point of being understandable? We help teams refactor toward charts that survive contact with multiple environments and teams. scopeforged.com