BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

Replace the bash script you keep rewriting

Every repo has a scripts/ folder that grew teeth. Here's how to turn the 200-line deploy.sh into a readable OrchStep workflow you can run, preview, and trust — without giving up the shell.

Jun 25, 2026 OrchStep Team 6 minROLE: Backend DeveloperSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/replace-bash-deploy
VIEW SOURCE

You know the script. It started as five lines that built and shipped a service. Two years later it's 240 lines of bash with set -euo pipefail, a dozen if [ -z "$VAR" ] guards, a retry loop copy-pasted from Stack Overflow, and a comment that just says # do not touch.

It works. Nobody understands it. Everybody is scared of it.

This post replaces that script with an OrchStep workflow — same commands, but with structure that used to be your problem: ordered steps, real variables, retries as syntax, and a dry-run that shows you the plan before anything executes. No platform, no daemon, no signup. One binary.

The before

Here's the kind of thing we're replacing — the "deploy" everyone copies and nobody reads:

DEPLOY.SH (BEFORE)
#!/usr/bin/env bash
set -euo pipefail

VERSION="${1:-}"
if [ -z "$VERSION" ]; then echo "version required"; exit 1; fi
ENV="${2:-staging}"

echo "building $VERSION"
docker build -t "api:$VERSION" .

# retry push up to 3x because the registry flakes
n=0
until [ $n -ge 3 ]; do
  docker push "api:$VERSION" && break
  n=$((n+1)); sleep $((2**n))
done
[ $n -ge 3 ] && { echo "push failed"; exit 1; }

kubectl --context "$ENV" set image deploy/api api="api:$VERSION"
kubectl --context "$ENV" rollout status deploy/api --timeout=120s \
  || kubectl --context "$ENV" rollout undo deploy/api
ORCHSTEP.YML (AFTER)
name: api
defaults:
  target: staging
tasks:
  deploy:
    steps:
      - name: build
        func: shell
        do: docker build -t api:{{ vars.version }} .
      - name: push
        func: shell
        do: docker push api:{{ vars.version }}
        retry:
          max_attempts: 3
          interval: "1s"
          backoff_rate: 2.0
      - name: rollout
        func: shell
        do: |
          kubectl --context {{ vars.target }} \
            set image deploy/api api=api:{{ vars.version }}
        catch:
          - name: undo
            func: shell
            do: kubectl --context {{ vars.target }} rollout undo deploy/api

Same docker and kubectl commands. But the retry is one line instead of a until loop with hand-rolled backoff, the rollback is a catch: block instead of a ||, and the version is a real variable instead of $1.

The full demo, file by file

The runnable project for this post is below — click through it. (orchstep init scaffolds this shape for you; nothing here is hand-typed boilerplate.)

orchstep.yml
name: api
# Workflow-wide defaults. Override any of these at the CLI with --var.
defaults:
  version: "1.0.0"
  target: staging
  registry: ghcr.io/acme

tasks:
  # `orchstep run build`
  build:
    steps:
      - name: compile
        func: shell
        do: echo "building api:{{ vars.version }}"
        outputs:
          image: "{{ vars.registry }}/api:{{ vars.version }}"
      - name: report
        func: shell
        do: echo "built {{ steps.compile.image }}"

  # `orchstep run deploy --var version=2.4.0 --var target=production`
  deploy:
    steps:
      - name: build
        task: build              # call another task as a step
      - name: push
        func: shell
        do: echo "pushing {{ vars.registry }}/api:{{ vars.version }}"
        retry:
          max_attempts: 3
          interval: "1s"
          backoff_rate: 2.0
      - name: rollout
        func: shell
        do: echo "rolling out {{ vars.version }} to {{ vars.target }}"
        catch:
          - name: rollback
            func: shell
            do: echo "rolling back {{ vars.target }}"
        finally:
          - name: notify
            func: shell
            do: echo "deploy of {{ vars.version }} finished"
      - name: gate
        if: '{{ eq vars.target "production" }}'
        then:
          - name: canary
            func: shell
            do: echo "canary verification in production"
        else:
          - name: done
            func: shell
            do: echo "staging rollout complete"

Run it

orchstep run deploy --var version=2.4.0 --var target=production

Don't remember the task name? Press a key, not your memory:

orchstep menu

You get a fuzzy task picker with single-key hotkeys — and it refuses to hang in a pipeline, so the same workflow is safe to call from CI.

See it before it runs

The part that retires the fear: a dry-run resolves variables, evaluates the if, and prints the exact plan — without executing a thing.

orchstep run deploy --var version=2.4.0 --var target=production --dry-run

You can see that the production branch takes the canary path before you ship it. That's the difference between "I think canary triggers in prod" and knowing it does. Full tour: Previewing with Dry Run.

What you actually gained

Concerndeploy.shOrchStep
Retry with backoffhand-rolled until loopretry: { max_attempts: 3, backoff_rate: 2.0 }
Rollback on failure|| after the commandcatch: block
Inputspositional $1, $2named vars + --var overrides
"What will this do?"read 240 lines--dry-run prints the plan
Reusabilitycopy-pastefunc: task calls another task
Discoverabilitygrep the fileorchstep menu

None of this hides the shell. docker, kubectl, and your existing tools still do the work — OrchStep just gives the plumbing a shape, so the next person (or the next you) can read it.

Where to go next

Got a deploy.sh you'd retire if this looked good? The shape above came straight from orchstep init — try it on yours.

#BASH#TASK-RUNNER#PRODUCTIVITY#GETTING-STARTED
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY