DOC_INDEX
THEME
DOCS/Use Cases/Blue/Green Rollout

Blue/Green Rollout

Deploy to the idle slot, verify before any traffic moves, flip atomically, keep instant rollback

The blue/green discipline: the new version never sees traffic until it has proven itself on the idle slot, the cutover is a single atomic pointer write, and the previous slot stays warm as an instant rollback target.

Executed for real against OrchStep v0.7.1 - including a deliberately broken release.

What it demonstrates

CapabilityWhere
State discoveryread_state derives active/idle from the pointer
Pre-traffic verificationverify_idle_slot with retry before the flip
Atomic cutoverflip_traffic writes one pointer file
Defense in depthpost_flip_check with catch-based auto-rollback
Failure injection--var force_bad_release=true

The workflow

name: bluegreen-rollout
desc: "Deploy to the idle slot, verify it, flip traffic atomically, keep the old slot for instant rollback"

defaults:
  slots_dir: "./slots"
  version: "2.4.0"
  force_bad_release: "false"

tasks:
  bootstrap:
    desc: "Create the initial blue slot serving v1.0.0 (demo helper)"
    steps:
      - name: init
        func: shell
        do: |
          rm -rf {{ vars.slots_dir }}
          mkdir -p {{ vars.slots_dir }}/blue
          echo "version=1.0.0" > {{ vars.slots_dir }}/blue/release.txt
          echo "healthy" > {{ vars.slots_dir }}/blue/health
          echo "blue" > {{ vars.slots_dir }}/ACTIVE
          echo "bootstrapped: blue serves v1.0.0"

  rollout:
    desc: "The full blue/green cycle for vars.version"
    steps:
      - name: read_state
        func: shell
        do: |
          ACTIVE=$(cat {{ vars.slots_dir }}/ACTIVE)
          if [ "$ACTIVE" = "blue" ]; then IDLE=green; else IDLE=blue; fi
          echo "active=$ACTIVE idle=$IDLE"
        outputs:
          active: '{{ result.output | regexFind "active=([a-z]+)" }}'
          idle: '{{ result.output | regexFind "idle=([a-z]+)" }}'

      - name: deploy_to_idle
        desc: "New version lands on the slot taking no traffic"
        func: shell
        do: |
          SLOT={{ vars.slots_dir }}/{{ steps.read_state.idle }}
          rm -rf $SLOT && mkdir -p $SLOT
          echo "version={{ vars.version }}" > $SLOT/release.txt
          if [ "{{ vars.force_bad_release }}" = "true" ]; then
            echo "crashed" > $SLOT/health
          else
            echo "healthy" > $SLOT/health
          fi
          echo "deployed {{ vars.version }} -> {{ steps.read_state.idle }}"

      - name: verify_idle_slot
        desc: "Health + smoke on the idle slot BEFORE any traffic moves"
        func: shell
        do: |
          SLOT={{ vars.slots_dir }}/{{ steps.read_state.idle }}
          grep -q "healthy" $SLOT/health && grep -q "version={{ vars.version }}" $SLOT/release.txt && echo "checks=pass"
        retry:
          max_attempts: 3
          interval: "200ms"
        outputs:
          checks: '{{ result.output | regexFind "checks=([a-z]+)" }}'

      - name: flip_traffic
        desc: "The cutover is one atomic pointer write"
        func: shell
        do: |
          echo "{{ steps.read_state.idle }}" > {{ vars.slots_dir }}/ACTIVE
          echo "traffic -> {{ steps.read_state.idle }}"

      - name: post_flip_check
        desc: "Verify the live slot; auto-rollback if it fails"
        func: shell
        do: |
          LIVE=$(cat {{ vars.slots_dir }}/ACTIVE)
          grep -q "healthy" {{ vars.slots_dir }}/$LIVE/health && echo "live=$LIVE status=ok"
        catch:
          - name: rollback
            desc: "Instant rollback: point back at the previous slot"
            func: shell
            do: |
              echo "{{ steps.read_state.active }}" > {{ vars.slots_dir }}/ACTIVE
              echo "ROLLED_BACK to {{ steps.read_state.active }}"
        outputs:
          status: '{{ result.output | regexFind "status=([a-z]+)" }}'

      - name: summary
        func: shell
        do: |
          ACTIVE=$(cat {{ vars.slots_dir }}/ACTIVE)
          echo "ROLLOUT_DONE live_slot=$ACTIVE version=$(cat {{ vars.slots_dir }}/$ACTIVE/release.txt)"

Run it

orchstep run bootstrap                 # blue serves v1.0.0
orchstep run rollout                   # v2.4.0 -> green, traffic flips
orchstep run rollout --var version=2.5.0-broken --var force_bad_release=true

Verified results:

ROLLOUT_DONE live_slot=green version=2.4.0

# broken release: idle verification fails after 3 attempts -> exit 1
# ACTIVE still points at green/v2.4.0 - the bad build never took traffic

Design notes

The gate sits before the flip. That is the whole point of blue/green: the broken release failed verify_idle_slot and the workflow stopped with traffic untouched. post_flip_check + rollback exists for regressions that only show under real traffic.

Slots are directories here, infrastructure in production. Swap the file operations for your load balancer's target-group API or a k8s service selector patch - the orchestration shape (discover, deploy idle, verify, flip, re-verify, rollback) is identical.