Building a Multi-Namespace Helm Chart with Environment Overlays
Structure a Helm chart that deploys cleanly to dev, staging, and production with different values per environment — without duplicating templates or maintaining separate charts per namespace.
Before you begin
- Helm 3 installed
- kubectl configured with cluster access
- Basic Helm knowledge (install
- upgrade
- template)
The typical Helm anti-pattern: one chart for dev, a fork for staging, another fork for prod. They diverge over time. A fix applied to one isn't applied to the others. Three months later, nobody's sure which is canonical.
The right approach: one chart, multiple values files, deployed to separate namespaces. This tutorial builds that structure from scratch.
The Target Structure
charts/api-server/
├── Chart.yaml
├── values.yaml # Defaults (safe for dev)
├── values-staging.yaml # Staging overrides
├── values-production.yaml # Production overrides
└── templates/
├── deployment.yaml
├── service.yaml
├── configmap.yaml
├── hpa.yaml
└── _helpers.tpl
Deploy commands:
1# Dev
2helm upgrade --install api-server ./charts/api-server \
3 -n dev --create-namespace
4
5# Staging
6helm upgrade --install api-server ./charts/api-server \
7 -n staging --create-namespace \
8 -f charts/api-server/values-staging.yaml
9
10# Production
11helm upgrade --install api-server ./charts/api-server \
12 -n production --create-namespace \
13 -f charts/api-server/values-production.yamlStep 1: Scaffold the Chart
mkdir -p charts/api-server/templatesStep 2: Chart.yaml
1cat > charts/api-server/Chart.yaml <<EOF
2apiVersion: v2
3name: api-server
4description: API server — deployed to dev, staging, and production
5type: application
6version: 0.1.0
7appVersion: "1.0.0"
8EOFStep 3: Default values.yaml
These are the dev defaults — permissive, low-resource, single replica:
1cat > charts/api-server/values.yaml <<EOF
2replicaCount: 1
3
4image:
5 repository: myregistry/api-server
6 tag: "latest"
7 pullPolicy: Always
8
9service:
10 type: ClusterIP
11 port: 80
12 targetPort: 8080
13
14resources:
15 requests:
16 cpu: 100m
17 memory: 128Mi
18 limits:
19 cpu: 500m
20 memory: 256Mi
21
22autoscaling:
23 enabled: false
24 minReplicas: 1
25 maxReplicas: 5
26 targetCPUUtilizationPercentage: 70
27
28env:
29 LOG_LEVEL: "debug"
30 DATABASE_URL: "postgres://dev-db:5432/appdb"
31
32probes:
33 readiness:
34 path: /healthz
35 initialDelaySeconds: 5
36 liveness:
37 path: /healthz
38 initialDelaySeconds: 15
39
40ingress:
41 enabled: false
42EOFStep 4: Staging Override Values
1cat > charts/api-server/values-staging.yaml <<EOF
2replicaCount: 2
3
4image:
5 tag: "staging"
6 pullPolicy: IfNotPresent
7
8resources:
9 requests:
10 cpu: 200m
11 memory: 256Mi
12 limits:
13 cpu: 1000m
14 memory: 512Mi
15
16autoscaling:
17 enabled: true
18 minReplicas: 2
19 maxReplicas: 8
20
21env:
22 LOG_LEVEL: "info"
23 DATABASE_URL: "postgres://staging-db:5432/appdb"
24
25ingress:
26 enabled: true
27 host: api.staging.example.com
28EOFStep 5: Production Override Values
1cat > charts/api-server/values-production.yaml <<EOF
2replicaCount: 3
3
4image:
5 tag: "v1.2.3" # Always pin in production
6 pullPolicy: IfNotPresent
7
8resources:
9 requests:
10 cpu: 500m
11 memory: 512Mi
12 limits:
13 cpu: 2000m
14 memory: 1Gi
15
16autoscaling:
17 enabled: true
18 minReplicas: 3
19 maxReplicas: 20
20
21env:
22 LOG_LEVEL: "warn"
23 DATABASE_URL: "postgres://prod-db:5432/appdb"
24
25probes:
26 readiness:
27 initialDelaySeconds: 10
28 liveness:
29 initialDelaySeconds: 30
30
31ingress:
32 enabled: true
33 host: api.example.com
34EOFStep 6: Templates
_helpers.tpl
1cat > charts/api-server/templates/_helpers.tpl <<'EOF'
2{{/*
3Expand the name of the chart.
4*/}}
5{{- define "api-server.name" -}}
6{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
7{{- end }}
8
9{{/*
10Create a default fully qualified app name.
11*/}}
12{{- define "api-server.fullname" -}}
13{{- printf "%s-%s" .Release.Name (include "api-server.name" .) | trunc 63 | trimSuffix "-" }}
14{{- end }}
15
16{{/*
17Common labels
18*/}}
19{{- define "api-server.labels" -}}
20helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
21app.kubernetes.io/name: {{ include "api-server.name" . }}
22app.kubernetes.io/instance: {{ .Release.Name }}
23app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
24app.kubernetes.io/managed-by: {{ .Release.Service }}
25{{- end }}
26EOFdeployment.yaml
1cat > charts/api-server/templates/deployment.yaml <<'EOF'
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ include "api-server.fullname" . }}
6 namespace: {{ .Release.Namespace }}
7 labels:
8 {{- include "api-server.labels" . | nindent 4 }}
9spec:
10 {{- if not .Values.autoscaling.enabled }}
11 replicas: {{ .Values.replicaCount }}
12 {{- end }}
13 selector:
14 matchLabels:
15 app.kubernetes.io/name: {{ include "api-server.name" . }}
16 app.kubernetes.io/instance: {{ .Release.Name }}
17 strategy:
18 type: RollingUpdate
19 rollingUpdate:
20 maxSurge: 1
21 maxUnavailable: 0
22 template:
23 metadata:
24 labels:
25 {{- include "api-server.labels" . | nindent 8 }}
26 spec:
27 terminationGracePeriodSeconds: 60
28 containers:
29 - name: api
30 image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
31 imagePullPolicy: {{ .Values.image.pullPolicy }}
32 ports:
33 - containerPort: {{ .Values.service.targetPort }}
34 env:
35 {{- range $key, $val := .Values.env }}
36 - name: {{ $key }}
37 value: {{ $val | quote }}
38 {{- end }}
39 readinessProbe:
40 httpGet:
41 path: {{ .Values.probes.readiness.path }}
42 port: {{ .Values.service.targetPort }}
43 initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
44 periodSeconds: 5
45 failureThreshold: 3
46 livenessProbe:
47 httpGet:
48 path: {{ .Values.probes.liveness.path }}
49 port: {{ .Values.service.targetPort }}
50 initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
51 periodSeconds: 10
52 failureThreshold: 3
53 resources:
54 {{- toYaml .Values.resources | nindent 12 }}
55 lifecycle:
56 preStop:
57 exec:
58 command: ["sleep", "15"]
59EOFhpa.yaml
1cat > charts/api-server/templates/hpa.yaml <<'EOF'
2{{- if .Values.autoscaling.enabled }}
3apiVersion: autoscaling/v2
4kind: HorizontalPodAutoscaler
5metadata:
6 name: {{ include "api-server.fullname" . }}
7 namespace: {{ .Release.Namespace }}
8spec:
9 scaleTargetRef:
10 apiVersion: apps/v1
11 kind: Deployment
12 name: {{ include "api-server.fullname" . }}
13 minReplicas: {{ .Values.autoscaling.minReplicas }}
14 maxReplicas: {{ .Values.autoscaling.maxReplicas }}
15 metrics:
16 - type: Resource
17 resource:
18 name: cpu
19 target:
20 type: Utilization
21 averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
22{{- end }}
23EOFStep 7: Validate Before Deploying
1# Render templates without deploying
2helm template api-server ./charts/api-server \
3 -n dev \
4 | kubectl apply --dry-run=client -f -
5
6# Render staging overlay
7helm template api-server ./charts/api-server \
8 -n staging \
9 -f charts/api-server/values-staging.yaml \
10 | kubectl apply --dry-run=client -f -
11
12# Lint
13helm lint ./charts/api-server
14helm lint ./charts/api-server -f charts/api-server/values-production.yamlStep 8: Deploy to All Environments
1# Dev
2helm upgrade --install api-server ./charts/api-server \
3 --namespace dev --create-namespace \
4 --atomic --timeout 3m
5
6# Staging
7helm upgrade --install api-server ./charts/api-server \
8 --namespace staging --create-namespace \
9 --atomic --timeout 3m \
10 -f charts/api-server/values-staging.yaml
11
12# Production (with explicit version)
13helm upgrade --install api-server ./charts/api-server \
14 --namespace production --create-namespace \
15 --atomic --timeout 5m \
16 -f charts/api-server/values-production.yaml \
17 --set image.tag=v1.2.3--atomic rolls back automatically if the deployment fails. --timeout sets a deadline for the rollout to complete.
Verify
1# Check releases across namespaces
2helm list -A
3
4# Compare actual values per environment
5helm get values api-server -n dev
6helm get values api-server -n production
7
8# Check running image tags
9kubectl get deployment api-server -n production \
10 -o jsonpath='{.spec.template.spec.containers[0].image}'The Key Rules
Pin image tags in production — latest in production is how you get silent breaking changes.
Keep defaults safe for dev — the base values.yaml should be the least dangerous configuration. Production adds constraints, not removes them.
Never commit secrets to values files — use --set at deploy time, external-secrets-operator, or Vault. Values files go in git; secrets don't.
Official References
- Helm Documentation — Official Helm docs: chart structure, templating, values, hooks, and the Helm CLI
- Chart Template Guide — In-depth guide to Go templating in Helm, including conditionals, loops, and named templates
- Helm Best Practices — Official guidance on chart structure, values naming conventions, and labels
- Helm Hooks — Pre/post install, upgrade, and delete hooks for running jobs at lifecycle events
- Library Charts — How to share templates across multiple charts to reduce duplication
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.