Platform Engineering
13 min readMay 9, 2026

Helm Advanced Patterns: Chart Development and Production Operations

Helm is Kubernetes' package manager — but production Helm usage goes well beyond helm install. This covers the patterns that matter at scale: library charts for shared templates, chart hooks for database migrations, OCI registry publishing, values schema validation, and the Helmfile pattern for managing multiple releases as code.

CO
Coding Protocols Team
Platform Engineering
Helm Advanced Patterns: Chart Development and Production Operations

Most teams use Helm as a deployment tool — helm install, helm upgrade, helm rollback. Production Helm usage requires more: custom charts that DRY up repeated Kubernetes patterns across services, values schema validation that catches configuration errors before deployment, and hooks that orchestrate database migrations without manual intervention.


Library Charts: Shared Templates Across Services

A library chart contains named templates that other charts can include. It can't be deployed on its own — its purpose is to be a dependency:

my-org-lib/           # Library chart
├── Chart.yaml
└── templates/
    ├── _deployment.tpl   # Shared deployment template
    ├── _service.tpl
    ├── _hpa.tpl
    └── _helpers.tpl      # Common label/annotation helpers
yaml
# my-org-lib/Chart.yaml
apiVersion: v2
name: my-org-lib
description: Shared Helm templates for My Org services
type: library    # Key: marks this as a library chart
version: 1.2.0
yaml
1# my-org-lib/templates/_deployment.tpl
2{{- define "myorg.deployment" -}}
3apiVersion: apps/v1
4kind: Deployment
5metadata:
6  name: {{ .Release.Name }}
7  labels:
8    {{- include "myorg.labels" . | nindent 4 }}
9spec:
10  replicas: {{ .Values.replicaCount | default 2 }}    # Note: default treats 0 as falsy — replicaCount: 0 will still render as 2
11  selector:
12    matchLabels:
13      app: {{ .Release.Name }}
14  template:
15    metadata:
16      labels:
17        app: {{ .Release.Name }}
18        {{- include "myorg.labels" . | nindent 8 }}
19    spec:
20      containers:
21        - name: {{ .Release.Name }}
22          image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
23          ports:
24            - containerPort: {{ .Values.service.port | default 8080 }}
25          resources:
26            {{- toYaml .Values.resources | nindent 12 }}
27          livenessProbe:
28            httpGet:
29              path: {{ .Values.healthCheck.path | default "/health" }}
30              port: {{ .Values.service.port | default 8080 }}
31            initialDelaySeconds: 30
32            periodSeconds: 30
33          readinessProbe:
34            httpGet:
35              path: {{ .Values.healthCheck.path | default "/health" }}
36              port: {{ .Values.service.port | default 8080 }}
37            initialDelaySeconds: 10
38            periodSeconds: 10
39{{- end -}}

Using the library chart in an application chart:

yaml
1# payments-api/Chart.yaml
2apiVersion: v2
3name: payments-api
4description: Payments API service
5version: 2.1.0
6dependencies:
7  - name: my-org-lib
8    version: "1.2.0"
9    repository: "oci://123456789.dkr.ecr.us-east-1.amazonaws.com/helm-charts"
yaml
# payments-api/templates/deployment.yaml
{{- include "myorg.deployment" . }}
yaml
# payments-api/templates/service.yaml
{{- include "myorg.service" . }}

The application chart's templates/ contains only include calls to the library. All boilerplate lives in one place.


Values Schema Validation

Helm supports JSON Schema validation for values files. This catches misconfiguration before Helm attempts to render templates:

json
1// values.schema.json
2{
3  "$schema": "http://json-schema.org/draft-07/schema#",
4  "type": "object",
5  "required": ["image", "service", "resources"],
6  "properties": {
7    "image": {
8      "type": "object",
9      "required": ["repository", "tag"],
10      "properties": {
11        "repository": {
12          "type": "string",
13          "description": "Container image repository"
14        },
15        "tag": {
16          "type": "string",
17          "description": "Image tag or digest",
18          "pattern": "^[a-zA-Z0-9._:-]+$"
19        }
20      }
21    },
22    "service": {
23      "type": "object",
24      "properties": {
25        "port": {
26          "type": "integer",
27          "minimum": 1,
28          "maximum": 65535,
29          "default": 8080
30        }
31      }
32    },
33    "resources": {
34      "type": "object",
35      "required": ["requests"],
36      "properties": {
37        "requests": {
38          "type": "object",
39          "required": ["cpu", "memory"],
40          "properties": {
41            "cpu": {"type": "string"},
42            "memory": {"type": "string"}
43          }
44        }
45      }
46    },
47    "replicaCount": {
48      "type": "integer",
49      "minimum": 1,
50      "maximum": 20,
51      "default": 2
52    }
53  }
54}

With values.schema.json in the chart root, helm install and helm upgrade validate values before rendering. A missing required field or wrong type produces a clear error message instead of a cryptic template error.

Note: Helm's JSON Schema validator uses JSON Schema draft-07 regardless of the $schema URI declared in the file. Draft 2020-12 features (like $defs or unevaluatedProperties) are not supported and will silently pass validation.


Chart Hooks: Database Migrations

Hooks run Jobs at specific points in the release lifecycle:

yaml
1# templates/pre-upgrade-migration.yaml
2apiVersion: batch/v1
3kind: Job
4metadata:
5  name: {{ .Release.Name }}-pre-upgrade-migration
6  labels:
7    {{- include "myorg.labels" . | nindent 4 }}
8  annotations:
9    "helm.sh/hook": pre-upgrade           # Run before upgrade
10    "helm.sh/hook-weight": "-5"           # Lower = runs first (multiple hooks)
11    "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
12    # before-hook-creation: delete previous run before creating new
13    # hook-succeeded: delete after successful completion
14spec:
15  backoffLimit: 0    # Don't retry on failure — let the upgrade fail
16  template:
17    spec:
18      restartPolicy: Never
19      containers:
20        - name: migration
21          image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
22          command: ["/app/migrate", "--up", "--verbose"]
23          env:
24            - name: DATABASE_URL
25              valueFrom:
26                secretKeyRef:
27                  name: {{ .Release.Name }}-db-credentials
28                  key: url

Hook phases: pre-install, post-install, pre-upgrade, post-upgrade, pre-rollback, post-rollback, pre-delete, post-delete, test.

If a pre-upgrade hook fails, Helm aborts the upgrade and the current release remains active.


OCI Registry Publishing

OCI chart support was introduced in Helm v3.8 behind HELM_EXPERIMENTAL_OCI=1 and became stable (GA) in Helm v3.12. Helm v3.12+ supports OCI as a chart registry without any feature flag (instead of the legacy helm repo add approach):

bash
1# Push chart to ECR
2aws ecr create-repository --repository-name helm-charts/payments-api
3
4# Log in to ECR
5aws ecr get-login-password --region us-east-1 | \
6  helm registry login \
7  --username AWS \
8  --password-stdin \
9  123456789.dkr.ecr.us-east-1.amazonaws.com
10
11# Package and push
12helm package payments-api/    # Creates payments-api-2.1.0.tgz
13
14helm push payments-api-2.1.0.tgz \
15  oci://123456789.dkr.ecr.us-east-1.amazonaws.com/helm-charts
16
17# Pull and install from OCI
18helm install payments-api \
19  oci://123456789.dkr.ecr.us-east-1.amazonaws.com/helm-charts/payments-api \
20  --version 2.1.0 \
21  --namespace payments \
22  --values production-values.yaml

OCI registries eliminate the need for a separate chart repository server. ECR, GHCR, and Docker Hub all support OCI artifact storage.


Last-Mile Patching: Helm Post-Renderers

In 2026, for third-party charts that don't expose enough knobs in values.yaml, we use Post-Renderers. This allows you to pipe Helm's output through Kustomize (or any binary) to apply patches before the final manifest is sent to Kubernetes:

bash
# A simple post-render script (kustomize-wrapper.sh)
#!/bin/bash
cat <&0 > all.yaml
kustomize build .
bash
# Install with the post-renderer
helm install my-app bitnami/nginx \
  --post-renderer ./kustomize-wrapper.sh

This pattern allows you to inject sidecars, add labels, or modify resources in upstream charts without forking them. It's the "cleanest" way to enforce platform standards on third-party software.


Helmfile: Multi-Release Management as Code

Helmfile manages multiple Helm releases as a single declarative file — similar to how Terraform manages infrastructure:

yaml
1# helmfile.yaml
2repositories:
3  - name: ingress-nginx
4    url: https://kubernetes.github.io/ingress-nginx
5  - name: jetstack
6    url: https://charts.jetstack.io
7
8releases:
9  - name: ingress-nginx
10    namespace: ingress-nginx
11    chart: ingress-nginx/ingress-nginx
12    version: 4.11.3
13    values:
14      - infra/nginx-values.yaml
15
16  - name: cert-manager
17    namespace: cert-manager
18    chart: jetstack/cert-manager
19    version: v1.16.2
20    set:
21      - name: crds.enabled
22        value: "true"
23
24  - name: payments-api
25    namespace: payments
26    chart: oci://123456789.dkr.ecr.us-east-1.amazonaws.com/helm-charts/payments-api
27    version: 2.1.0
28    values:
29      - apps/payments/values.yaml
30      - apps/payments/values-production.yaml
31    needs:
32      - cert-manager/cert-manager    # Ensure cert-manager is installed first
bash
1# Apply all releases
2helmfile sync
3
4# Diff what would change
5helmfile diff
6
7# Apply only releases that changed
8helmfile apply
9
10# Target a specific release
11helmfile -l name=payments-api sync

Helm with Argo CD

Argo CD renders Helm charts server-side and tracks their output. For a split-repo GitOps setup (chart in OCI, values in a Git repo), use Argo CD's multiple sources feature (GA in v2.8+):

yaml
1apiVersion: argoproj.io/v1alpha1
2kind: Application
3metadata:
4  name: payments-api
5  namespace: argocd
6spec:
7  sources:
8    # Source 1: The Helm chart from OCI registry
9    - repoURL: oci://123456789.dkr.ecr.us-east-1.amazonaws.com/helm-charts
10      chart: payments-api
11      targetRevision: "2.1.0"
12      helm:
13        releaseName: payments-api
14        valueFiles:
15          - $values/apps/payments/values-prod.yaml    # $values references the second source
16
17    # Source 2: Values files from a separate Git config repo
18    - repoURL: https://github.com/my-org/platform-config.git
19      targetRevision: main
20      ref: values    # Defines the $values variable used above

For single-source deployments (chart and values in the same repo), valueFiles accepts repo-relative paths:

yaml
1spec:
2  source:
3    repoURL: https://github.com/my-org/charts.git
4    path: payments-api
5    targetRevision: main
6    helm:
7      releaseName: payments-api
8      valueFiles:
9        - values.yaml               # Relative to the chart path
10        - ../../config/values-prod.yaml    # Traverse up to a config directory
11      values: |
12        # Inline override (highest precedence)
13        replicaCount: 3

Note: valueFiles only accepts repo-relative paths — not HTTPS URLs. To pull values from a separate repository, use multiple sources as shown above.

Argo CD's Helm integration diffs the rendered manifests (not the chart itself) — it shows exactly which Kubernetes resources would change, not which chart values changed.


Frequently Asked Questions

Should I write custom charts or use upstream charts with values?

Upstream charts (bitnami, cert-manager, etc.) for infrastructure components — they're well-maintained and cover most cases. Custom charts for your application services — upstream charts can't know your organization's conventions, labels, or library templates. Library charts let you maintain DRY templates without forking upstream charts.

How do I handle different environments (dev, staging, prod) with Helm?

Multiple values files layered at install time. Base values in values.yaml (defaults that work everywhere), environment-specific overrides in values-prod.yaml. Install with --values values.yaml --values values-prod.yaml (later files override earlier). For Argo CD, use valueFiles in the Application spec to pull environment-specific values from a separate config repo.

Can Helm manage CRDs safely?

Helm's CRD handling has quirks. CRDs in crds/ are installed before templates but never updated or deleted by Helm. This is intentional — CRD changes can break existing custom resources. For CRDs that change frequently (like cert-manager's), use the crds.enabled: true value that installs CRDs as regular resources (upgradeable but deletable). For operator CRDs (Prometheus, etc.), use the operator's own CRD management tooling rather than relying on Helm.


For Argo CD GitOps workflows that deploy Helm charts across environments, see Argo CD for GitOps-Based Kubernetes Deployments. For Helm best practices for production deployments (chart structure, testing, CI), see Helm Best Practices for Production Kubernetes.

Building a Helm library for multiple application teams on a shared platform? Talk to us at Coding Protocols — we help platform teams design chart architectures that standardize deployments without creating a maintenance bottleneck.

Related Topics

Helm
Kubernetes
Platform Engineering
CI/CD
GitOps
Chart Development
Deployment
EKS

Read Next