Skip to main content
Kubernetes·12 min read

Helm Charts Across Multiple Environments — Patterns That Don't Turn Into Copy-Paste Hell

Create, template, and manage Helm charts for production Kubernetes deployments. Covers multi-environment values, chart dependencies, hooks, versioning strategies, and rollback procedures.

DT

DevOps Engineer & Technical Writer

Why Helm Still Matters

HELM CHART WORKFLOW — FROM TEMPLATES TO DEPLOYED RESOURCES HELM CHART templates/*.yaml values.yaml Chart.yaml RENDERING Helm CLI install / upgrade CLUSTER K8s API apply manifests DEPLOYED Deployment Service Ingress HPA values-production.yaml overrides → per-environment configuration

Kubernetes YAML is verbose, repetitive, and error-prone at scale. When you're managing 15 microservices across 4 environments, raw manifests become unmaintainable. Helm solves this by giving you templating, packaging, versioning, and rollback — the package manager Kubernetes needed.

But most teams use Helm at a tutorial level: helm install and pray. Production Helm requires understanding chart structure, values inheritance, dependency management, and proper release lifecycle. Let's go deeper.

Chart Structure That Scales

my-service/

├── Chart.yaml # Chart metadata and dependencies

├── Chart.lock # Dependency lock file

├── values.yaml # Default values

├── values-staging.yaml # Environment overrides

├── values-production.yaml # Environment overrides

├── templates/

│ ├── _helpers.tpl # Template helpers and named templates

│ ├── deployment.yaml

│ ├── service.yaml

│ ├── hpa.yaml

│ ├── ingress.yaml

│ ├── serviceaccount.yaml

│ ├── configmap.yaml

│ ├── pdb.yaml # Pod Disruption Budget

│ ├── networkpolicy.yaml

│ ├── servicemonitor.yaml # Prometheus scraping

│ └── tests/

│ └── test-connection.yaml

├── charts/ # Dependency charts

└── .helmignore

Chart.yaml

apiVersion: v2

name: api-service

description: Order processing API service

type: application

version: 1.4.2 # Chart version (bump on chart changes)

appVersion: "2.3.1" # Application version (what's deployed)

dependencies:

- name: postgresql

version: "13.2.x"

repository: "https://charts.bitnami.com/bitnami"

condition: postgresql.enabled

- name: redis

version: "18.x.x"

repository: "https://charts.bitnami.com/bitnami"

condition: redis.enabled

Templating for Real Applications

The _helpers.tpl Foundation

{{/ templates/_helpers.tpl /}}

{{- define "api-service.name" -}}

{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}

{{- end }}

{{- define "api-service.fullname" -}}

{{- if .Values.fullnameOverride }}

{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}

{{- else }}

{{- $name := default .Chart.Name .Values.nameOverride }}

{{- if contains $name .Release.Name }}

{{- .Release.Name | trunc 63 | trimSuffix "-" }}

{{- else }}

{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}

{{- end }}

{{- end }}

{{- end }}

{{- define "api-service.labels" -}}

helm.sh/chart: {{ include "api-service.chart" . }}

app.kubernetes.io/name: {{ include "api-service.name" . }}

app.kubernetes.io/instance: {{ .Release.Name }}

app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}

app.kubernetes.io/managed-by: {{ .Release.Service }}

app.kubernetes.io/component: api

app.kubernetes.io/part-of: order-system

{{- end }}

{{- define "api-service.selectorLabels" -}}

app.kubernetes.io/name: {{ include "api-service.name" . }}

app.kubernetes.io/instance: {{ .Release.Name }}

{{- end }}

{{- define "api-service.chart" -}}

{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}

{{- end }}

Production Deployment Template

{{/ templates/deployment.yaml /}}

apiVersion: apps/v1

kind: Deployment

metadata:

name: {{ include "api-service.fullname" . }}

labels:

{{- include "api-service.labels" . | nindent 4 }}

annotations:

{{- with .Values.deploymentAnnotations }}

{{- toYaml . | nindent 4 }}

{{- end }}

spec:

{{- if not .Values.autoscaling.enabled }}

replicas: {{ .Values.replicaCount }}

{{- end }}

strategy:

type: RollingUpdate

rollingUpdate:

maxSurge: {{ .Values.strategy.maxSurge | default "25%" }}

maxUnavailable: {{ .Values.strategy.maxUnavailable | default 0 }}

selector:

matchLabels:

{{- include "api-service.selectorLabels" . | nindent 6 }}

template:

metadata:

annotations:

checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}

{{- with .Values.podAnnotations }}

{{- toYaml . | nindent 8 }}

{{- end }}

labels:

{{- include "api-service.selectorLabels" . | nindent 8 }}

spec:

serviceAccountName: {{ include "api-service.fullname" . }}

securityContext:

{{- toYaml .Values.podSecurityContext | nindent 8 }}

{{- with .Values.imagePullSecrets }}

imagePullSecrets:

{{- toYaml . | nindent 8 }}

{{- end }}

containers:

- name: {{ .Chart.Name }}

securityContext:

{{- toYaml .Values.securityContext | nindent 12 }}

image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

imagePullPolicy: {{ .Values.image.pullPolicy }}

ports:

- name: http

containerPort: {{ .Values.service.targetPort | default 8080 }}

protocol: TCP

- name: metrics

containerPort: {{ .Values.metrics.port | default 9090 }}

protocol: TCP

env:

{{- range $key, $value := .Values.env }}

- name: {{ $key }}

value: {{ $value | quote }}

{{- end }}

envFrom:

{{- if .Values.existingSecret }}

- secretRef:

name: {{ .Values.existingSecret }}

{{- end }}

- configMapRef:

name: {{ include "api-service.fullname" . }}

startupProbe:

httpGet:

path: {{ .Values.health.startup.path | default "/health/ready" }}

port: http

periodSeconds: 2

failureThreshold: 30

readinessProbe:

httpGet:

path: {{ .Values.health.readiness.path | default "/health/ready" }}

port: http

periodSeconds: {{ .Values.health.readiness.periodSeconds | default 5 }}

failureThreshold: {{ .Values.health.readiness.failureThreshold | default 3 }}

livenessProbe:

httpGet:

path: {{ .Values.health.liveness.path | default "/health/live" }}

port: http

periodSeconds: {{ .Values.health.liveness.periodSeconds | default 10 }}

failureThreshold: {{ .Values.health.liveness.failureThreshold | default 3 }}

resources:

{{- toYaml .Values.resources | nindent 12 }}

lifecycle:

preStop:

exec:

command: ["/bin/sh", "-c", "sleep 10"]

terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds | default 60 }}

{{- with .Values.nodeSelector }}

nodeSelector:

{{- toYaml . | nindent 8 }}

{{- end }}

{{- with .Values.tolerations }}

tolerations:

{{- toYaml . | nindent 8 }}

{{- end }}

{{- with .Values.affinity }}

affinity:

{{- toYaml . | nindent 8 }}

{{- end }}

topologySpreadConstraints:

- maxSkew: 1

topologyKey: topology.kubernetes.io/zone

whenUnsatisfiable: DoNotSchedule

labelSelector:

matchLabels:

{{- include "api-service.selectorLabels" . | nindent 14 }}

Multi-Environment Values

Default values.yaml

# values.yaml — sane defaults, overridden per environment

replicaCount: 2

image:

repository: myregistry/api-service

pullPolicy: IfNotPresent

tag: "" # Defaults to Chart.appVersion

service:

type: ClusterIP

port: 80

targetPort: 8080

ingress:

enabled: false

resources:

requests:

cpu: 100m

memory: 256Mi

limits:

memory: 512Mi

autoscaling:

enabled: false

minReplicas: 2

maxReplicas: 10

targetCPUUtilizationPercentage: 70

env:

LOG_LEVEL: "info"

SERVICE_NAME: "api-service"

health:

readiness:

path: /health/ready

periodSeconds: 5

failureThreshold: 3

liveness:

path: /health/live

periodSeconds: 10

failureThreshold: 3

podSecurityContext:

runAsNonRoot: true

runAsUser: 1000

fsGroup: 1000

securityContext:

allowPrivilegeEscalation: false

readOnlyRootFilesystem: true

capabilities:

drop:

- ALL

postgresql:

enabled: false

redis:

enabled: false

values-production.yaml

# values-production.yaml — production overrides

replicaCount: 6

image:

tag: "2.3.1"

ingress:

enabled: true

className: nginx

annotations:

cert-manager.io/cluster-issuer: letsencrypt-prod

nginx.ingress.kubernetes.io/rate-limit-rps: "100"

hosts:

- host: api.mycompany.com

paths:

- path: /

pathType: Prefix

tls:

- secretName: api-tls

hosts:

- api.mycompany.com

resources:

requests:

cpu: 500m

memory: 1Gi

limits:

memory: 2Gi

autoscaling:

enabled: true

minReplicas: 6

maxReplicas: 20

targetCPUUtilizationPercentage: 60

env:

LOG_LEVEL: "warn"

ENABLE_METRICS: "true"

DB_POOL_SIZE: "20"

existingSecret: api-database-secret

postgresql:

enabled: false # Using managed RDS in production

values-staging.yaml

# values-staging.yaml

replicaCount: 2

image:

tag: "2.3.1-rc.1"

ingress:

enabled: true

className: nginx

hosts:

- host: api.staging.internal

paths:

- path: /

pathType: Prefix

resources:

requests:

cpu: 100m

memory: 256Mi

limits:

memory: 512Mi

env:

LOG_LEVEL: "debug"

postgresql:

enabled: true # Run PostgreSQL in-cluster for staging

auth:

postgresPassword: staging-pass

database: orders

Deploying Per Environment

# Staging

helm upgrade --install api-service ./my-service \

--namespace staging \

--values ./my-service/values-staging.yaml \

--set image.tag="2.3.1-rc.2" \

--wait --timeout 5m

# Production

helm upgrade --install api-service ./my-service \

--namespace production \

--values ./my-service/values-production.yaml \

--set image.tag="2.3.1" \

--wait --timeout 10m \

--atomic # Auto-rollback on failure

Helm Hooks for Lifecycle Events

Database Migration Before Deployment

{{/ templates/migration-job.yaml /}}

{{- if .Values.migrations.enabled }}

apiVersion: batch/v1

kind: Job

metadata:

name: {{ include "api-service.fullname" . }}-migrate-{{ .Release.Revision }}

labels:

{{- include "api-service.labels" . | nindent 4 }}

annotations:

"helm.sh/hook": pre-upgrade,pre-install

"helm.sh/hook-weight": "-5"

"helm.sh/hook-delete-policy": before-hook-creation

spec:

backoffLimit: 3

activeDeadlineSeconds: 300

template:

spec:

restartPolicy: Never

containers:

- name: migrate

image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

command: ["./migrate", "up"]

envFrom:

- secretRef:

name: {{ .Values.existingSecret }}

{{- end }}

Smoke Test After Deployment

{{/ templates/tests/test-connection.yaml /}}

apiVersion: v1

kind: Pod

metadata:

name: {{ include "api-service.fullname" . }}-test

labels:

{{- include "api-service.labels" . | nindent 4 }}

annotations:

"helm.sh/hook": test

"helm.sh/hook-delete-policy": before-hook-creation

spec:

restartPolicy: Never

containers:

- name: test

image: curlimages/curl:8.5.0

command:

- sh

- -c

- |

echo "Testing health endpoint..."

curl -sf http://{{ include "api-service.fullname" . }}:{{ .Values.service.port }}/health/ready || exit 1

echo "Testing API response..."

curl -sf http://{{ include "api-service.fullname" . }}:{{ .Values.service.port }}/api/v1/status || exit 1

echo "All tests passed!"

Versioning Strategy

Follow semantic versioning for charts:

  • Patch (1.4.1 → 1.4.2): Template fixes, default value changes, docs
  • Minor (1.4.2 → 1.5.0): New optional features, new templates, non-breaking
  • Major (1.5.0 → 2.0.0): Breaking changes to values schema, removed templates

# In CI/CD pipeline

# Lint before publishing

helm lint ./my-service --values ./my-service/values-production.yaml

# Template and validate

helm template api-service ./my-service \

--values ./my-service/values-production.yaml | \

kubectl apply --dry-run=server -f -

# Package and push to OCI registry

helm package ./my-service

helm push my-service-1.4.2.tgz oci://myregistry.azurecr.io/helm-charts

Rollbacks Done Right

# View release history

helm history api-service -n production

# Rollback to previous revision

helm rollback api-service 0 -n production --wait

# Rollback to specific revision

helm rollback api-service 12 -n production --wait --timeout 5m

# Check what changed between revisions

helm diff revision api-service 12 13 # requires helm-diff plugin

The --atomic flag during upgrades is your safety net:

helm upgrade --install api-service ./my-service \

--namespace production \

--values ./my-service/values-production.yaml \

--atomic \

--timeout 10m

If any pod fails to become ready within the timeout, Helm automatically rolls back to the previous release. No manual intervention needed.

Best Practices Checklist

  • Always use --atomic in production — auto-rollback on failure.
  • Pin dependency versions — use Chart.lock and commit it.
  • Template everything, hardcode nothing — if it varies between environments, it's a value.
  • Use checksum/config annotation — triggers pod restart when ConfigMap changes.
  • Set resource requests AND limits — prevent noisy neighbor and OOM kills.
  • Include PodDisruptionBudget — protect against node drains.
  • Test charts in CIhelm lint, helm template, kubeval, kubectl --dry-run=server.
  • Separate chart version from app version — they have different lifecycles.
  • Use .helmignore — exclude tests, CI files, and documentation from packaged charts.
  • Document values — every value in values.yaml should have a comment explaining its purpose.
  • Final Thought

    Helm is infrastructure-as-code for Kubernetes. Treat your charts with the same rigor as your application code: version them, test them, review them in PRs, and automate their deployment. The teams that get Helm right deploy confidently across environments without YAML drift or configuration surprises. The teams that don't end up with a pile of ad-hoc kubectl apply scripts that nobody understands.

    ---

    Frequently Asked Questions

    What is Helm and why should I use it for Kubernetes?

    Helm is a package manager for Kubernetes that bundles related manifests into charts with templating and versioning. It simplifies deploying complex applications, manages releases with upgrade/rollback capabilities, and allows customization through values files. Use Helm when deploying multi-resource applications that need reproducible installations across environments.

    How do I roll back a Helm release?

    Run helm rollback <release-name> <revision-number> to revert to a previous release version. Use helm history <release-name> to see all revisions with their status and timestamps. Helm stores release history in Kubernetes secrets by default, so rollbacks are fast — they reapply the previous manifest set.

    What is the difference between Helm 2 and Helm 3?

    Helm 3 removed Tiller (the server-side component that required cluster-admin privileges), making Helm client-only and more secure. It also uses 3-way merge for upgrades (comparing live state, old chart, and new chart), stores releases as secrets instead of ConfigMaps, and supports library charts. Always use Helm 3 for new projects.

    How do I manage Helm values across environments?

    Create a base values.yaml with defaults and environment-specific override files like values-staging.yaml and values-production.yaml. Deploy with helm install -f values.yaml -f values-production.yaml. Use Helmfile for managing multiple charts and environments declaratively, or ArgoCD with ApplicationSets for GitOps-based value management.

    How do I create a custom Helm chart?

    Run helm create mychart to scaffold a standard chart structure with templates, values, and helpers. Customize the templates in templates/, define configurable values in values.yaml, and add dependencies in Chart.yaml. Test locally with helm template mychart to render manifests and helm lint to check for issues before deploying.

    ---