DOC_INDEX
THEME
DOCS/Use Cases/Environment Promotion

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

CapabilityWhere
Environment separationenv_groups (shared) + environments (per-env)
Config renderingheredoc in render_config (templates interpolate first)
Parallel validationsmoke_checks runs three checks concurrently
Retry with backoffhealth_check (passes on the second attempt by design)
Production-only approvalapproval_gate runs only where require_approval is true
Env-conditional checkscheck_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 first

Verified 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.com

Note 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 it

staging 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.