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.
blog/replace-bash-deployYou 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:
#!/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/apiname: 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/apiSame 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.)
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=productionDon't remember the task name? Press a key, not your memory:
orchstep menuYou 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-runYou 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
| Concern | deploy.sh | OrchStep |
|---|---|---|
| Retry with backoff | hand-rolled until loop | retry: { max_attempts: 3, backoff_rate: 2.0 } |
| Rollback on failure | || after the command | catch: block |
| Inputs | positional $1, $2 | named vars + --var overrides |
| "What will this do?" | read 240 lines | --dry-run prints the plan |
| Reusability | copy-paste | func: task calls another task |
| Discoverability | grep the file | orchstep 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
- Quick Start — your first workflow in two minutes
- Variables & Outputs — the four-level scoping model
- Error Handling — retry, catch, finally, timeouts
Got a deploy.sh you'd retire if this looked good? The shape above came straight from orchstep init — try it on yours.
curl -fsSL https://orchstep.dev/install.sh | sh