Cloud Native 36 min read

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.

Cloud Architecture
Cloud Architecture
Cloud Architecture
Build Once, Deploy Hundreds: Helm Multi‑Environment Delivery Practices & Pitfalls

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

Charts 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.yaml

Helmfile 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.yaml

Values 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 15m

Helmfile 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/redis

Helmfile 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=true

For 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: 6

Key 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 . --strict

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

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CI/CDKubernetesDevOpsGitOpsHelmMulti-Environment
Cloud Architecture
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.