Build Once, Deploy Hundreds: Helm Multi‑Environment Delivery Practices & Pitfalls
This article explains how to turn Helm from a simple templating tool into a production‑grade, multi‑environment delivery framework by covering Helm's core concepts, engineering upgrades, values schema, CI/CD integration, GitOps workflows, high‑concurrency considerations, and a comprehensive list of common pitfalls and best‑practice recommendations.
Why many teams "use Helm" but still lack true engineering delivery
Teams often think Helm only parameterises Kubernetes YAML, which is only half the story. The real challenge is keeping the delivery pipeline stable, auditable, and scalable when the system grows from a single environment to dev/test/staging/pre‑prod/prod/dr, from a few services to dozens, from manual releases to GitOps, and from low traffic to high‑concurrency scenarios.
Typical incidents stem from:
Submitting staging values to prod.
Temporarily overriding critical fields with --set and losing the true configuration on rollback.
Sub‑chart version drift causing inconsistent renders.
Over‑optimistic readiness probes that route traffic to un‑warmed pods.
Skipping diff, policy, or schema checks for faster releases.
All these share the root cause: using Helm without a production‑grade delivery contract.
Helm's core purpose
Helm is not just a template engine; it is a packaging protocol for Kubernetes applications, similar to deb/rpm in Linux. It solves three problems:
Packaging : bundles resources, defaults, and dependencies into a single deployable unit.
Installation : creates a versioned, stateful Release.
Upgrade : supports diff, rollback, dependency updates, atomic releases, and failure recovery.
The core objects are Chart (the package), Values (environment contract), and Release (the concrete instantiation).
Chart is the delivery template, Values are the environment contract, Release is the deployment fact.
Helm 3 architectural shift
Helm 2’s Tiller required high privileges inside the cluster and was a security concern. Helm 3 removes Tiller, letting the client talk directly to the API server, which brings three benefits:
Clearer permission boundaries using native RBAC.
Lightweight architecture without a long‑running control component.
Release state stored as native objects (Secret or ConfigMap), simplifying audit and integration.
Release history is persisted as sh.helm.release.v1.<release-name>.v<revision>, so excessive history or large charts can bloat etcd.
Common template pitfalls
Turning templates into business scripts – heavy if/else, deep nesting, and recursive tpl make maintenance explode.
Treating values as an arbitrary JSON bag instead of a strongly‑constrained input model.
Neglecting default and empty‑value handling, leading to nil‑pointer errors when a sub‑chart is disabled.
Production‑grade chart design principles:
Keep templates thin; push business decisions into the values structure.
Defensive handling for all optional fields.
Validate with a JSON schema instead of relying on docs.
Reuse Library Charts instead of copy‑pasting.
{{- $resources := .Values.orderService.resources | default dict -}}
{{- $requests := $resources.requests | default dict -}}
resources:
requests:
cpu: {{ $requests.cpu | default "200m" | quote }}
memory: {{ $requests.memory | default "256Mi" | quote }}Compared to directly accessing .Values.orderService.resources.requests.cpu, this defensive pattern avoids crashes when fields are missing.
Four‑layer delivery architecture
+---------------------------+
| 4. GitOps control layer |
| ArgoCD / Flux: watch Git, |
| sync, rollback, drift fix |
+---------------------------+
| 3. Application orchestration |
| Helmfile / ApplicationSet |
+---------------------------+
| 2. Application packaging |
| Helm Chart: templates, defaults, deps, version, schema, hooks |
+---------------------------+
| 1. Runtime artifacts |
| Docker image, Config, Secret, external deps, DB, MQ |
+---------------------------+Responsibilities must not be mixed: Helm packages the app, Helmfile composes multiple charts, ArgoCD ensures the desired state, and CI builds images and validates artifacts.
Directory layout for clean separation
platform-delivery/
├── apps/
│ ├── order-service/
│ │ ├── src/
│ │ ├── Dockerfile
│ │ └── Makefile
│ └── payment-service/
├── charts/
│ ├── order-service/
│ │ ├── Chart.yaml
│ │ ├── values.yaml
│ │ ├── values.schema.json
│ │ └── templates/…
│ └── platform-library/…
├── envs/
│ ├── dev/
│ │ ├── order-service.yaml
│ │ └── payment-service.yaml
│ ├── staging/
│ └── prod/
├── helmfile/
│ ├── helmfile.yaml
│ └── environments/
├── argocd/
│ ├── projects/
│ ├── applications/
│ └── applicationsets/
└── .gitlab-ci.ymlCharts live under charts/, environment‑specific overrides under envs/, and orchestration under helmfile/ and argocd/.
Values design for multi‑dimensional environments
Instead of a single values-prod.yaml, use composable files that can be merged:
values/
├── base.yaml
├── env/
│ ├── dev.yaml
│ ├── staging.yaml
│ └── prod.yaml
├── region/
│ ├── cn-east-1.yaml
│ └── cn-north-1.yaml
├── size/
│ ├── small.yaml
│ ├── medium.yaml
│ └── large.yaml
└── tenant/
├── shared.yaml
└── vip.yamlHelmfile merges them in order, e.g.:
values:
- values/base.yaml
- values/env/prod.yaml
- values/region/cn-east-1.yaml
- values/size/large.yaml
- values/tenant/vip.yamlValues merging semantics
Maps are merged by key (later values overwrite earlier keys).
Lists are replaced entirely, not merged element‑wise. --set is suitable only for simple overrides, not complex structures.
Therefore, avoid putting large lists (e.g., tolerations, hosts, env) in --set and prefer explicit value files.
Production‑grade chart snippets
Deployment template highlights
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "order-service.fullname" . }}
labels:
{{- include "order-service.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
revisionHistoryLimit: 5
strategy:
type: {{ .Values.deploymentStrategy.type }}
rollingUpdate:
maxSurge: {{ .Values.deploymentStrategy.rollingUpdate.maxSurge }}
maxUnavailable: {{ .Values.deploymentStrategy.rollingUpdate.maxUnavailable }}
selector:
matchLabels:
{{- include "order-service.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "order-service.selectorLabels" . | nindent 8 }}
{{- with .Values.global.commonLabels }}{{ toYaml . | nindent 8 }}{{- end }}
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
terminationGracePeriodSeconds: 60
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.global.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
env:
- name: SPRING_PROFILES_ACTIVE
value: {{ .Values.config.springProfilesActive | quote }}
- name: JAVA_TOOL_OPTIONS
value: >-
-Xms{{ .Values.config.jvm.xms }}
-Xmx{{ .Values.config.jvm.xmx }}
-Dlogging.level.root={{ .Values.config.logLevel }}
envFrom:
- configMapRef:
name: {{ include "order-service.fullname" . }}
- secretRef:
name: {{ .Values.secrets.externalSecretName }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.readiness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
timeoutSeconds: {{ .Values.probes.liveness.timeoutSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
{{- if .Values.probes.startup.enabled }}
startupProbe:
httpGet:
path: {{ .Values.probes.startup.path }}
port: http
failureThreshold: {{ .Values.probes.startup.failureThreshold }}
periodSeconds: {{ .Values.probes.startup.periodSeconds }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
{{- with .Values.nodePlacement.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodePlacement.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.nodePlacement.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}Key production knobs: revisionHistoryLimit prevents unlimited Deployment history. checksum/config forces rolling updates when ConfigMaps change. startupProbe avoids killing slow‑starting pods. preStop + terminationGracePeriodSeconds give connections time to drain. maxUnavailable: 0 guarantees no loss of availability during rolling updates.
HPA tuned for business bottlenecks
Typical HPA only scales on CPU, which is insufficient for many services. A more complete HPA includes memory and can be replaced by KEDA for custom metrics such as Kafka lag or queue depth.
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "order-service.fullname" . }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "order-service.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 20
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
{{- end }}CI/CD and GitOps workflow
CI stage checklist
YAML and template syntax validation. helm lint --strict.
Validate values.schema.json.
Render results for each environment.
Kubernetes API compatibility checks.
Policy validation.
Diff preview.
Chart packaging and signing.
Sample GitLab CI pipeline (attributes stripped):
stages:
- test
- build-image
- lint-chart
- package-chart
- deploy-staging
- verify
- promote-prod
variables:
HELM_VERSION: "3.18.4"
HELMFILE_VERSION: "0.169.0"
CHART_DIR: "charts/order-service"
CHART_REPO: "oci://harbor.example.com/charts"
lint-chart:
stage: lint-chart
image: alpine/helm:${HELM_VERSION}
script:
- helm dependency update ${CHART_DIR}
- helm lint ${CHART_DIR} --strict
- helm template order-service ${CHART_DIR} -f envs/dev/order-service.yaml > rendered-dev.yaml
- helm template order-service ${CHART_DIR} -f envs/prod/order-service.yaml > rendered-prod.yaml
package-chart:
stage: package-chart
image: alpine/helm:${HELM_VERSION}
script:
- helm dependency update ${CHART_DIR}
- helm package ${CHART_DIR} --destination dist
- helm push dist/order-service-*.tgz ${CHART_REPO}
artifacts:
paths:
- dist/
deploy-staging:
stage: deploy-staging
image: ghcr.io/helmfile/helmfile:${HELMFILE_VERSION}
script:
- helmfile -f helmfile/helmfile.yaml -e staging diff
- helmfile -f helmfile/helmfile.yaml -e staging apply --wait --timeout 10m
promote-prod:
stage: promote-prod
image: ghcr.io/helmfile/helmfile:${HELMFILE_VERSION}
when: manual
script:
- helmfile -f helmfile/helmfile.yaml -e prod diff
- helmfile -f helmfile/helmfile.yaml -e prod apply --wait --timeout 15mHelmfile for multi‑service orchestration
repositories:
- name: bitnami
url: https://charts.bitnami.com/bitnami
- name: internal
url: oci://harbor.example.com/charts
helmDefaults:
wait: true
timeout: 600
atomic: true
cleanupOnFail: true
createNamespace: true
environments:
dev:
values:
- ../envs/dev/order-service.yaml
staging:
values:
- ../envs/staging/order-service.yaml
prod:
values:
- ../envs/prod/order-service.yaml
releases:
- name: redis
namespace: platform
chart: bitnami/redis
version: 19.6.0
installed: {{ ne .Environment.Name "prod" }}
- name: order-service
namespace: {{ .Environment.Name }}
chart: ../charts/order-service
values:
- ../envs/{{ .Environment.Name }}/order-service.yaml
needs:
- platform/redisHelmfile separates environment‑specific values from the chart, keeping the chart pure.
ArgoCD as the GitOps engine
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: order-service-prod
namespace: argocd
spec:
project: production
source:
repoURL: https://git.example.com/platform-delivery.git
targetRevision: main
path: charts/order-service
helm:
releaseName: order-service
valueFiles:
- ../../envs/prod/order-service.yaml
destination:
server: https://kubernetes.default.svc
namespace: prod
syncPolicy:
syncOptions:
- CreateNamespace=true
- ApplyOutOfSyncOnly=trueFor many services, an ApplicationSet can generate applications from a matrix of env/region/size/tenant.
High‑concurrency considerations
Every values field can affect throughput and stability: replicaCount, resource requests/limits, HPA thresholds, PDB, probes, thread‑pool sizes, DB pool sizes, etc. A mature values file must model capacity per environment, not just dev vs prod differences.
Example order‑service capacity for a flash‑sale:
replicaCount: 8
resources:
requests:
cpu: 1500m
memory: 2Gi
limits:
cpu: 3000m
memory: 3Gi
autoscaling:
enabled: true
minReplicas: 8
maxReplicas: 40
targetCPUUtilizationPercentage: 55
targetMemoryUtilizationPercentage: 70
config:
threadPool:
tomcatMaxThreads: 1200
tomcatAcceptCount: 5000
datasource:
hikariMaximumPoolSize: 120
hikariMinimumIdle: 30
podDisruptionBudget:
enabled: true
minAvailable: 6Key insight: scaling pods does not guarantee the whole system can handle traffic; dependent services (DB, Redis, Kafka) must also be sized accordingly.
Security and compliance
Never store plain secrets in Git. Use External Secrets Operator, helm-secrets with SOPS, or inject secrets at CI time. Example ExternalSecret template:
{{- if .Values.secrets.enabled }}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ .Values.secrets.externalSecretName }}
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: {{ .Values.secrets.externalSecretName }}
data:
- secretKey: DB_USERNAME
remoteRef:
key: kv/order-service/prod
property: {{ .Values.secrets.data.dbUsernameKey }}
- secretKey: DB_PASSWORD
remoteRef:
key: kv/order-service/prod
property: {{ .Values.secrets.data.dbPasswordKey }}
{{- end }}Policy‑as‑code tools (Conftest + OPA, Kyverno, Gatekeeper) enforce non‑root containers, required resource requests/limits, disallow latest tags in prod, mandatory TLS for Ingress, and NetworkPolicy for high‑risk namespaces.
Common production pitfalls
Release Secret bloat – limit --history-max to 5‑10 and avoid embedding large JSON or certificates in values.
Sub‑chart version drift – always commit Chart.lock and run helm dependency update in CI.
Overusing Helm hooks – keep hooks idempotent, bounded in time, and prefer dedicated migration jobs.
Embedding all environment differences in templates – move differences to values files and keep templates generic.
Assuming a successful release means business readiness – add post‑deployment verification (health checks, metric regression, error‑rate monitoring, tracing).
Platform evolution
When the number of services grows, introduce Library Charts to share common Deployment, Service, HPA, PDB, ServiceMonitor, and NetworkPolicy templates. Then let application teams fill only the unique fields via values.
Self‑service portals (e.g., Backstage) can collect app name, port, owner, domain, capacity tier, and automatically generate a standard Chart and environment values, wiring it into CI, ArgoCD, monitoring, and alerting pipelines.
Deployment checklist
helm lint . --strictpasses. values.schema.json validates critical fields.
All target environments render successfully with helm template.
Production never uses latest image tags. Chart.lock is committed for reproducible dependencies.
All secrets are managed via external secret stores or encryption.
Readiness, liveness, and startup probes reflect real startup characteristics.
Resources, HPA, and PDB match load‑test results.
Pre‑deployment helm diff clearly shows changes.
Rollback path is rehearsed and does not rely on manual steps.
Monitoring, alerts, logs, and tracing cover critical paths.
Conclusion
Helm’s true value is not just reducing YAML duplication; it standardises the entire delivery contract: from immutable images, through versioned charts, to auditable, repeatable deployments across any number of environments, regions, capacities, and tenants.
When Helm is embedded in a full platform‑engineered workflow, it becomes the universal delivery DSL that enables "build once, deploy hundreds" with confidence, safety, and scalability.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Cloud Architecture
Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
