Environment Promotion
One deployment workflow promoted through dev, staging, and production with environment-scoped configuration
The same deploy task serves three environments. All environment-specific
values live in env_groups and environments blocks - the steps never
mention an environment by name. Switching targets is purely
orchstep run deploy --env staging.
Everything on this page was executed against OrchStep v0.7.1 for all three environments; the rendered production config below is the real file.
What it demonstrates
| Capability | Where |
|---|---|
| Environment separation | env_groups (shared) + environments (per-env) |
| Config rendering | heredoc in render_config (templates interpolate first) |
| Parallel validation | smoke_checks runs three checks concurrently |
| Retry with backoff | health_check (passes on the second attempt by design) |
| Production-only approval | approval_gate runs only where require_approval is true |
| Env-conditional checks | check_no_beta_in_prod only executes in production |
The workflow
name: service-promotion
desc: "Promote checkout-service through dev -> staging -> production with env-scoped config"
defaults:
app_name: "checkout-service"
version: "1.4.2"
deploy_root: "/tmp/orchstep-deploys"
require_approval: "false"
env_groups:
nonprod:
vars:
replicas: "2"
log_level: "debug"
feature_flags: "beta_checkout,new_pricing"
db_pool: "5"
prod:
vars:
replicas: "8"
log_level: "error"
feature_flags: ""
db_pool: "50"
require_approval: "true"
environments:
dev:
group: nonprod
vars:
env_name: "dev"
db_host: "dev-db.internal:5432"
base_url: "https://dev.checkout.internal"
staging:
group: nonprod
vars:
env_name: "staging"
replicas: "4"
db_host: "staging-db.internal:5432"
base_url: "https://staging.checkout.internal"
production:
group: prod
vars:
env_name: "production"
db_host: "prod-db.internal:5432"
base_url: "https://checkout.example.com"
tasks:
deploy:
desc: "Deploy the service to the selected environment (-e/--env)"
steps:
- name: approval_gate
desc: "Production requires an explicit yes"
if: '{{ eq vars.require_approval "true" }}'
then:
- name: confirm
func: prompt
args:
message: "Deploy {{ vars.app_name }} v{{ vars.version }} to {{ vars.env_name }}?"
type: confirm
default: "true"
- name: enforce
func: assert
args:
condition: '{{ eq steps.confirm.value "true" }}'
desc: "Operator approved the production deploy"
- name: render_config
desc: "Render the environment-scoped app config"
func: shell
do: |
mkdir -p {{ vars.deploy_root }}/{{ vars.env_name }}
cat > {{ vars.deploy_root }}/{{ vars.env_name }}/app.conf <<CONF
# generated by orchstep for {{ vars.env_name }}
app = {{ vars.app_name }}
version = {{ vars.version }}
replicas = {{ vars.replicas }}
log_level = {{ vars.log_level }}
db_host = {{ vars.db_host }}
db_pool = {{ vars.db_pool }}
flags = {{ vars.feature_flags }}
base_url = {{ vars.base_url }}
CONF
echo "wrote {{ vars.deploy_root }}/{{ vars.env_name }}/app.conf"
outputs:
config_path: "{{ vars.deploy_root }}/{{ vars.env_name }}/app.conf"
- name: smoke_checks
desc: "Validate the rendered deployment in parallel"
parallel:
- name: check_replicas
func: shell
do: grep "replicas = {{ vars.replicas }}" {{ steps.render_config.config_path }}
outputs: { ok: "true" }
- name: check_db
func: shell
do: grep "db_host = {{ vars.db_host }}" {{ steps.render_config.config_path }}
outputs: { ok: "true" }
- name: check_no_beta_in_prod
if: '{{ eq vars.env_name "production" }}'
then:
- name: assert_no_flags
func: shell
do: '! grep -q "beta_checkout" {{ vars.deploy_root }}/production/app.conf'
outputs: { ok: "true" }
- name: health_check
desc: "Health endpoint with retry/backoff (simulated: passes on 2nd attempt)"
func: shell
do: |
MARK={{ vars.deploy_root }}/{{ vars.env_name }}/.healthy
if [ -f "$MARK" ]; then
echo "status=200 env={{ vars.env_name }}"
else
touch "$MARK"
echo "service warming up"; exit 1
fi
retry:
max_attempts: 4
interval: "200ms"
backoff_rate: 2.0
max_delay: "2s"
outputs:
status: '{{ result.output | regexFind "status=([0-9]+)" }}'
- name: announce
func: shell
do: 'echo "DEPLOYED {{ vars.app_name }} v{{ vars.version }} -> {{ vars.env_name }} (replicas={{ vars.replicas }}, health={{ steps.health_check.status }})"'
promote:
desc: "Full promotion: this task documents the order; run each env via --env"
steps:
- name: instructions
func: shell
do: |
echo "Run the pipeline per environment:"
echo " orchstep run deploy --env dev"
echo " orchstep run deploy --env staging"
echo " orchstep run deploy --env production"Run it
orchstep run deploy --env dev
orchstep run deploy --env staging
orchstep run deploy --env production # prompts for approval firstVerified results:
DEPLOYED checkout-service v1.4.2 -> dev (replicas=2, health=200)
DEPLOYED checkout-service v1.4.2 -> staging (replicas=4, health=200)
DEPLOYED checkout-service v1.4.2 -> production (replicas=8, health=200)The real rendered production config:
# generated by orchstep for production
app = checkout-service
version = 1.4.2
replicas = 8
log_level = error
db_host = prod-db.internal:5432
db_pool = 50
flags =
base_url = https://checkout.example.comNote replicas=8 and empty flags - production inherited the prod group
and dropped the beta feature flags that dev and staging carry.
How the variable layers resolve
For --env staging the engine merges, lowest to highest precedence:
defaults: app_name, version, require_approval=false
env_groups.nonprod.vars: replicas=2, log_level=debug, feature_flags=beta...
environments.staging.vars: env_name, db_host, base_url, replicas=4 <- overrides the group
--var / --vars-file: anything you pass at the CLI wins over all of itstaging gets replicas=4 (its own override) while dev keeps the group's
2 - the standard group-plus-override shape that keeps per-env blocks tiny.
Design notes
Steps never know the environment. Every step reads plain vars.*. That
is the discipline that makes promotion safe: there is nothing to forget to
change when you add an environment - add a block under environments: and
you are done.
A note on the retry demo. The health check intentionally fails its first
attempt (a marker file makes the second succeed) so you can watch
retry/backoff_rate work. The engine prints a failure box for the failed
attempt and then retries - the run still exits 0.
Take it to production
Replace the heredoc render with your real config management, the grep smoke
checks with kubectl rollout status / real http health probes, and wire
announce to your chat tool. The environment model is the part you keep.