One workflow, every environment
Stop forking your deploy script per environment. Declare shared values in groups, per-target values in environments, and pick the target at the CLI with --env — the steps never change.
blog/one-workflow-every-envThere are two ways teams end up handling "dev vs staging vs prod," and both go bad.
The first is a wall of if [ "$ENV" = "prod" ] branches inside one script. The second is deploy-dev.sh, deploy-staging.sh, deploy-prod.sh — three files that started identical and drifted the day someone fixed a bug in one and forgot the others. Either way, the environment leaks into the logic, and the logic is now three slightly different things you have to keep in your head.
OrchStep splits those apart. The steps describe what a deploy does once. The environments describe what is different about each target. You pick the target with --env at the command line, and the steps never mention it.
The shape of the fix
#!/usr/bin/env bash
ENV="${1:?env required}"
if [ "$ENV" = "prod" ]; then
REPLICAS=10; DB=prod-db; LOG=error
elif [ "$ENV" = "staging" ]; then
REPLICAS=4; DB=staging-db; LOG=debug
else
REPLICAS=2; DB=dev-db; LOG=debug
fi
echo "deploy replicas=$REPLICAS db=$DB log=$LOG"
echo "health check against $DB"name: one-workflow
defaults:
replicas: "1"
db_host: localhost
log_level: info
environments:
dev: { group: nonprod, vars: { db_host: dev-db, replicas: "2" } }
staging: { group: nonprod, vars: { db_host: staging-db, replicas: "4" } }
production: { group: prod, vars: { db_host: prod-db, replicas: "10" } }
tasks:
deploy:
steps:
- { name: plan, func: shell, do: 'echo "deploy replicas={{ vars.replicas }} db={{ vars.db_host }} log={{ vars.log_level }}"' }
- { name: health, func: shell, do: 'echo "health check against {{ vars.db_host }}"' }The if/elif/else is gone. Adding a fourth environment on the left means another branch in a growing tree; on the right it is one more line under environments:. The step that runs the deploy is identical for every target — which means it is tested by every target.
Groups carry what environments share
Most of your environments are not unique snowflakes; they fall into a couple of families. Dev and staging both want debug logging and a cheap monitoring tier. Production wants error-level logs and the full tier. That shared layer is a group, and an environment inherits its group, then overrides only what is genuinely different.
Here is the full runnable workflow for this post — click through it:
name: one-workflow
# defaults < group < environment, selected with --env.
# The steps never mention an environment — they only read {{ vars.* }}.
defaults:
replicas: "1"
db_host: localhost
log_level: info
monitoring: none
env_groups:
nonprod:
vars:
log_level: debug
monitoring: basic
prod:
vars:
log_level: error
monitoring: full
environments:
dev:
group: nonprod
vars:
db_host: dev-db
replicas: "2"
staging:
group: nonprod
vars:
db_host: staging-db
replicas: "4" # env overrides its group
production:
group: prod
vars:
db_host: prod-db
replicas: "10"
tasks:
deploy:
steps:
- name: plan
func: shell
do: 'echo "deploy replicas={{ vars.replicas }} db={{ vars.db_host }} log={{ vars.log_level }} mon={{ vars.monitoring }}"'
- name: health
func: shell
do: 'echo "health check against {{ vars.db_host }}"'The precedence is just most-specific-wins: defaults is the floor, the group layers shared family values on top, and the environment has the final say. staging sets replicas: "4" even though its nonprod group says nothing about replicas — the environment line wins.
Pick the target at the CLI
orchstep run deploy --env dev # replicas=2 db=dev-db log=debug
orchstep run deploy --env staging # replicas=4 db=staging-db log=debug
orchstep run deploy --env production # replicas=10 db=prod-db log=errorAnd when a developer needs to pin one value for a single run — a smaller replica count to reproduce a bug — --var always wins over the selected environment, no file edit required:
orchstep run deploy --env production --var replicas=1That ordering is the whole model in one line: defaults < group < environment < --var.
What you actually gained
| Concern | Per-env scripts | OrchStep |
|---|---|---|
| Add an environment | new branch or new file | one block under environments: |
| Shared family values | copy-pasted | one group, inherited |
| The deploy logic | N slightly different copies | one task, every target runs it |
| Override one value for a run | edit a file | --var replicas=1 |
| "Which env am I about to hit?" | read the script | --env <name>, explicit |
This is the inline style — everything in one orchstep.yml. It is the right call for a handful of environments.
Or move it all out into files
You don't have to keep env_groups: and environments: in the workflow at all. Point OrchStep at a directory and it reads one file per layer — the workflow drops to just the tasks, and the values live in environments/. Here is the exact config above, externalized:
name: one-workflow
# No env_groups/environments here — they live in environments/*.yml.
env_config:
env_dir: environments
tasks:
deploy:
steps:
- name: plan
func: shell
do: 'echo "deploy replicas={{ vars.replicas }} db={{ vars.db_host }} log={{ vars.log_level }} mon={{ vars.monitoring }}"'
- name: health
func: shell
do: 'echo "health check against {{ vars.db_host }}"'The filename is the hierarchy: <group>.yml is the group, <group>-<env>.yml is the leaf. So the env name becomes the compound form, and --env nonprod-staging loads defaults.yml → nonprod.yml → nonprod-staging.yml, resolving to the same values as inline --env staging:
orchstep run deploy --env nonprod-dev # replicas=2 db=dev-db log=debug
orchstep run deploy --env nonprod-staging # replicas=4 db=staging-db log=debug
orchstep run deploy --env prod-production # replicas=10 db=prod-db log=errorThe steps and the model don't change — only where the values live. Inline is the right call for a few environments; externalize the moment the blocks grow or different people own different targets. The next post covers the naming rules and edge cases in full.
Where it is not the answer
If you genuinely have one environment, skip all of this — a plain task with defaults: is enough. Environments earn their keep the moment you have a second target that shares most, but not all, of the first one's config.
Where to go next
- Environments & Variable Files — both styles, the full precedence ladder
- Variables & Outputs — the scoping model behind
vars - Environment Promotion — dev → staging → prod with gates
Clone the runnable demo above from orchstep-demos — the steps only echo, so swap them for your real kubectl/terraform and it goes live.
curl -fsSL https://orchstep.dev/install.sh | sh