Helm Chart Environment Overrides for Staging and Production Deployments

devops-kubernetes

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 -f flag merge behavior described here applies to Helm 3. Helm 2 is end-of-life and behaves differently.
  • A working Kubernetes cluster with kubectl configured and namespaces staging and production either existing or creatable.
  • An existing Helm chart at ./chart relative to your repo root. The chart must expose replicaCount, image, ingress, resources, and env keys 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.secretKeyRef or 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 -f flag 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; latest in 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 template in 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.

official docs

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank