When a single Helm chart needs to serve both staging and production, the temptation is to duplicate values files and drift them independently over time. A cleaner approach is a layered override pattern: one base file that captures shared defaults, and per-environment files that override only what needs to change — replicas, resource budgets, ingress hostnames, image tags.
This tutorial covers the full workflow: structuring the file layout, writing the override files, wiring them into helm upgrade --install, and verifying rendered output before anything touches the cluster.
Requirements
Before starting, confirm the following are in place:
- Helm 3.x — the
-fflag merge behavior described here applies to Helm 3. Helm 2 is end-of-life and behaves differently. - A working Kubernetes cluster with
kubectlconfigured and namespacesstagingandproductioneither existing or creatable. - An existing Helm chart at
./chartrelative to your repo root. The chart must exposereplicaCount,image,ingress,resources, andenvkeys in its templates. - Registry access — the examples reference
registry.kuryzhev.cloud/myapp. Substitute your own registry and ensure pull credentials are configured in both namespaces. - No secrets in values files — anything sensitive must use
valueFrom.secretKeyRefor an external secrets operator. This tutorial does not cover secrets management, but that constraint applies throughout.
Implementation
The directory layout keeps all environment files alongside the chart in version control, under an envs/ directory at the repo root. This makes environment-specific changes visible in pull requests rather than applied ad hoc from someone’s workstation.
repo-root/
├── chart/
│ ├── Chart.yaml
│ ├── templates/
│ └── values.yaml ← base defaults
└── envs/
├── values-staging.yaml ← staging overrides
└── values-production.yaml ← production overrides
The base values.yaml lives inside the chart directory and contains conservative defaults that would be safe to deploy anywhere. The override files live under envs/ and contain only the keys that differ. Helm merges them in order: the last -f flag wins on any conflicting key.
Here is the full set of values files. Each file is annotated with its purpose:
# chart/values.yaml — base defaults shared across all environments
replicaCount: 1
image:
repository: registry.kuryzhev.cloud/myapp
tag: "1.4.2"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: true
host: myapp.example.com
tls: false
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
env:
LOG_LEVEL: "info"
FEATURE_FLAGS: "false"
---
# envs/values-staging.yaml — staging overrides
replicaCount: 1
image:
tag: "latest"
pullPolicy: Always
ingress:
host: myapp-staging.kuryzhev.cloud
tls: false
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "150m"
memory: "128Mi"
env:
LOG_LEVEL: "debug"
FEATURE_FLAGS: "true"
---
# envs/values-production.yaml — production overrides
replicaCount: 3
image:
tag: "1.4.2"
pullPolicy: IfNotPresent
ingress:
host: myapp.kuryzhev.cloud
tls: true
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
env:
LOG_LEVEL: "warn"
FEATURE_FLAGS: "false"
A few decisions worth noting in these files. Staging uses image.tag: "latest" with pullPolicy: Always — acceptable for a fast-feedback environment where you want every deploy to pick up the newest build. Production pins the tag explicitly to 1.4.2 and uses IfNotPresent to avoid unintended image pulls. The ingress.host values are fully distinct, which prevents any chance of a staging deploy accidentally routing production traffic. Resource requests in staging are intentionally lower to reduce cost on a smaller cluster.
With the files in place, the deploy commands follow a consistent pattern. Both staging and production use helm upgrade --install so the same command handles first-time installs and subsequent updates without branching logic in your CI pipeline:
# Deploy to staging
helm upgrade --install myapp ./chart \
-f chart/values.yaml \
-f envs/values-staging.yaml \
--namespace staging --create-namespace
# Deploy to production
helm upgrade --install myapp ./chart \
-f chart/values.yaml \
-f envs/values-production.yaml \
--namespace production --create-namespace
One common mistake to avoid: if you used helm install initially with both -f flags, you must also pass both flags on every subsequent helm upgrade. Helm does not persist which values files were used — it only stores the merged result. Dropping a flag on upgrade will silently revert any key that was only present in the omitted file.
Test the Setup
Before deploying to either environment, use helm template to render the full manifest locally. This produces the same YAML that would be sent to the API server, without requiring cluster access:
# Render staging manifests to stdout
helm template myapp ./chart \
-f chart/values.yaml \
-f envs/values-staging.yaml
# Render production manifests to stdout
helm template myapp ./chart \
-f chart/values.yaml \
-f envs/values-production.yaml
Pipe the output through grep to quickly spot-check specific values. For example, confirming replica count and ingress host for each environment:
# Check replica count in staging render
helm template myapp ./chart \
-f chart/values.yaml \
-f envs/values-staging.yaml | grep -E "replicas:|host:"
# Expected output (staging):
# replicas: 1
# host: myapp-staging.kuryzhev.cloud
# Check replica count in production render
helm template myapp ./chart \
-f chart/values.yaml \
-f envs/values-production.yaml | grep -E "replicas:|host:"
# Expected output (production):
# replicas: 3
# host: myapp.kuryzhev.cloud
For validation that goes beyond local rendering and actually exercises the Kubernetes API, add --dry-run to the upgrade command. This sends the manifests to the API server for schema and admission webhook validation without committing the release:
helm upgrade --install myapp ./chart \
-f chart/values.yaml \
-f envs/values-staging.yaml \
--namespace staging \
--dry-run
Once a release is live, you can retrieve the values that Helm actually applied to a running release. This is useful for auditing what is currently deployed versus what is in your files:
# Retrieve applied values from a live staging release
helm get values myapp -n staging
# Retrieve applied values from a live production release
helm get values myapp -n production
If the output of helm get values diverges from what is in your envs/ files, someone deployed manually without using the repo. That is the signal to enforce CI-only deploys and treat the files as the authoritative source.
- The last
-fflag always wins — base file first, environment override second, every time. - Override files should contain only the keys that differ; repeating unchanged keys creates maintenance noise and merge confusion.
- Staging and production ingress hosts must be distinct to prevent accidental traffic routing between environments.
- Pin image tags in production;
latestin staging is a deliberate tradeoff, not a default to copy. - Resource requests and limits should reflect actual environment sizing — staging running production-scale resources wastes budget, and production running staging-scale limits causes throttling under load.
- Use
helm templatein CI to diff rendered output across environments before merging changes to the override files. - Never store secrets in values files; the pattern here applies only to non-sensitive configuration.
