Promote one build through every environment
Build the artifact once, then walk the exact same bytes from dev to staging to prod with a single --env flag. No rebuilds, no per-environment copy-paste, no drift.
blog/promote-buildThe rule everyone agrees with and nobody enforces: build once, deploy many. The artifact that passed tests in staging should be the literal same artifact that lands in production — same bytes, same hash, no rebuild "just to be safe."
The reason it slips is mechanical. Each environment ends up with its own deploy script, its own copy of the values, its own subtly different version string. Three environments, three scripts, and the moment one drifts you're debugging a difference that shouldn't exist.
This post collapses that into one workflow and one flag. The artifact is named once. Dev, staging, and production are just value bundles you select with --env.
Before and after
# deploy-dev.sh
docker build -t api:dev .
kubectl --context dev-cluster set image deploy/api api=api:dev
# deploy-staging.sh
docker build -t api:rc .
kubectl --context staging-cluster set image deploy/api api=api:rc
# deploy-prod.sh (someone bumped this one in March, the others didn't)
docker build -t api:1.8.0 .
kubectl --context prod-cluster set image deploy/api api=api:1.8.0name: promote
defaults:
version: "1.8.0"
artifact: ghcr.io/acme/api
environments:
dev: { group: nonprod, vars: { target: dev-cluster } }
staging: { group: nonprod, vars: { target: staging-cluster } }
production: { group: prod, vars: { target: prod-cluster } }
tasks:
deploy:
steps:
- name: deploy
func: shell
do: echo "deploying {{ vars.artifact }}:{{ vars.version }} to {{ vars.target }}"Three scripts rebuilt three artifacts and drifted. One workflow names the artifact once and the only thing that changes between runs is which environment bundle you select.
The full project
env_groups hold values shared by a family of environments (dev and staging are both nonprod). environments are the per-target bundles you select with --env. The artifact and version live in defaults, so they are identical no matter which environment you pick.
name: promote
# One artifact, three environments. Select with --env dev|staging|production.
defaults:
version: "1.8.0"
artifact: ghcr.io/acme/api
# Shared values for a family of environments.
env_groups:
nonprod:
vars: { monitoring: basic, approval: auto }
prod:
vars: { monitoring: full, approval: manual }
# Per-target bundles. Each selects a group and overrides a few vars.
environments:
dev:
group: nonprod
vars: { target: dev-cluster }
staging:
group: nonprod
vars: { target: staging-cluster }
production:
group: prod
vars: { target: prod-cluster }
tasks:
# orchstep run deploy --env staging
deploy:
steps:
- name: resolve
func: shell
do: echo "promoting {{ vars.artifact }}:{{ vars.version }} to {{ vars.target }}"
- name: deploy
func: shell
do: echo "deploying the same artifact {{ vars.artifact }}:{{ vars.version }} ({{ vars.monitoring }} monitoring)"
- name: gate
if: '{{ eq vars.approval "manual" }}'
then:
- name: hold
func: shell
do: echo "{{ vars.target }} requires manual approval before promotion"
else:
- name: auto
func: shell
do: echo "auto-promoted to {{ vars.target }}"Walk it up the ladder
orchstep run deploy --env dev
orchstep run deploy --env staging
orchstep run deploy --env productionSame task, same artifact string, three targets. The gate step reads approval from the environment's group — nonprod auto-promotes, prod holds for a human — so the policy travels with the environment instead of living in a different script.
Want to see what production will resolve to before you run it?
orchstep run deploy --env production --dry-runThe plan prints prod-cluster, full monitoring, and the manual-approval branch, with the artifact unchanged from dev. The env_groups/environments model is documented in full at Environments.
What you actually gained
| Concern | Three scripts | OrchStep |
|---|---|---|
| Artifact identity | rebuilt per environment | named once in defaults |
| Per-environment values | copy-pasted, drift over time | environments: bundles |
| Shared family values | duplicated | env_groups: |
| Promotion policy | hard-coded per script | travels with the env (approval) |
| Switching targets | a different file | --env <name> |
Where OrchStep stops
OrchStep selects and resolves the environment; it does not store your secrets or build your image. The echos stand in for your real registry and kubectl calls — the promotion structure around them is what this buys you. If you only ever ship to one place, you don't need any of this; a single script is fine. The moment there are two or more environments and "build once" matters, one workflow beats three scripts that drift.
Where to go next
- Environments —
env_groups,environments, and--env - Variables & Outputs — how values resolve and override
- Quick Start — your first workflow in two minutes
The demo is runnable — every step only echos. Try orchstep run deploy --env production --dry-run to watch the same artifact resolve against the prod bundle.
curl -fsSL https://orchstep.dev/install.sh | sh