Render Kubernetes manifests per environment
Stop maintaining near-identical YAML for staging and prod. Keep one manifest template, feed it per-environment vars, and apply — without pulling in a whole templating platform.
blog/render-k8s-manifestsEvery Kubernetes setup hits the same fork. You have a Deployment for staging and a Deployment for production that differ by exactly three things — replica count, namespace, ingress host — and yet they live as two full copies of YAML. Change the container spec and you have to remember to change it in both. Forget one and the environments drift.
You don't always need Helm or Kustomize for this. If the difference is a handful of values, a render step over one template covers it.
One template, per-environment values
The render function takes a template and a set of vars and produces text. Put the per-environment values in environments: and the same template serves both:
name: k8s-manifests
defaults:
app: api
image: ghcr.io/acme/api:1.5.0
stage: staging
replicas: "2"
host: staging.example.com
# Per-environment values feed the same templates.
environments:
staging:
vars: { stage: staging, replicas: "2", host: staging.example.com }
production:
vars: { stage: production, replicas: "5", host: example.com }
tasks:
# orchstep run apply --env production
apply:
desc: "Render the Deployment + Ingress for one environment and apply"
steps:
- name: deployment
func: render
args:
template: |
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ vars.app }}
namespace: {{ vars.stage }}
spec:
replicas: {{ vars.replicas }}
template:
spec:
containers:
- name: {{ vars.app }}
image: {{ vars.image }}
outputs:
manifest: "{{ result.output }}"
- name: ingress
func: render
do: "host: {{ vars.host }} # ingress for {{ vars.stage }}"
outputs:
rule: "{{ result.output }}"
- name: apply
func: shell
do: echo "kubectl apply -n {{ vars.stage }} (Deployment {{ vars.app }} x{{ vars.replicas }} + Ingress {{ vars.host }})"The container spec exists once. staging gets 2 replicas in the staging namespace; production gets 5 in production with the apex host. To render the manifest to disk for kubectl apply -f, add args.output_file to the render step — here we keep it in an output so the demo is side-effect-free.
Render for one environment
orchstep run apply --env productionThe --env selection feeds replicas: 5 and host: example.com into the template, the manifest comes out the back, and the apply step ships it. Same task, same template, different environment.
Diff before you apply
The reason teams reach for templating in the first place is to avoid surprises in prod. A dry-run lets you see the resolved plan — which environment's values went in — before kubectl touches the cluster:
orchstep run apply --env production --dry-runnamespace: staging
replicas: 2
host: staging.example.comnamespace: production
replicas: 5
host: example.comSame template, two value sets. The thing that differs is config, and it's the only thing that differs.
What you gained
| Concern | Two manifests | OrchStep render |
|---|---|---|
| Container spec | duplicated | written once |
| Per-env differences | scattered in both files | environments: vars |
| Add an environment | copy the whole manifest | one environments: entry |
| Render + apply | two manual steps | one task |
| Preview | eyeball the YAML | --env <name> --dry-run |
Where Helm still wins
If you need charts, dependencies, a release history, and helm rollback, use Helm — that's its job. This pattern is for the common middle ground: a few manifests that differ by a few values, where a full chart is more machinery than the problem deserves.
Where to go next
- The render function — templates to text or files
- Environments — per-environment value sets
- Previewing with Dry Run — resolve before you apply
The project above is a runnable, echo-only demo. Swap the final echo for a real kubectl apply to make it live.
curl -fsSL https://orchstep.dev/install.sh | sh