BLOG/CONFIGURATION MANAGEMENT
THEME
CONFIGURATION MANAGEMENT

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.

May 18, 2026 OrchStep Team 6 minROLE: DevOps EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/one-workflow-every-env
VIEW SOURCE

There 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

ENVIRONMENT BAKED INTO THE SCRIPT
#!/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"
VALUES DECLARED, STEPS GENERIC
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:

orchstep.yml
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=error

And 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=1

That ordering is the whole model in one line: defaults < group < environment < --var.

What you actually gained

ConcernPer-env scriptsOrchStep
Add an environmentnew branch or new fileone block under environments:
Shared family valuescopy-pastedone group, inherited
The deploy logicN slightly different copiesone task, every target runs it
Override one value for a runedit 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:

orchstep.yml
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.ymlnonprod.ymlnonprod-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=error

The 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

Clone the runnable demo above from orchstep-demos — the steps only echo, so swap them for your real kubectl/terraform and it goes live.

#ENVIRONMENTS#CONFIGURATION#DEPLOYMENT#DEVOPS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — CONFIGURATION MANAGEMENT