BLOG/TEAM CI/CD
THEME
TEAM CI/CD

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.

May 29, 2026 OrchStep Team 6 minROLE: DevOps EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/promote-build
VIEW SOURCE

The 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

THREE SCRIPTS (BEFORE)
# 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.0
ONE WORKFLOW (AFTER)
name: 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.

orchstep.yml
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 production

Same 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-run

The 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

ConcernThree scriptsOrchStep
Artifact identityrebuilt per environmentnamed once in defaults
Per-environment valuescopy-pasted, drift over timeenvironments: bundles
Shared family valuesduplicatedenv_groups:
Promotion policyhard-coded per scripttravels with the env (approval)
Switching targetsa 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

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.

#ENVIRONMENTS#PROMOTION#DEPLOY#CI-CD
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD