Docker builds you can actually read
The build-tag-push dance is always the same, always slightly wrong in each repo. Here it is as one OrchStep task: multi-stage build, an image ref computed once, and a push that retries the flaky registry.
blog/docker-buildsEvery repo's container build is the same three moves — build, tag, push — and every repo gets them subtly wrong in its own way. The image ref is spelled out in three places that have already drifted. The registry flakes one time in twenty, so there's a retry loop someone pasted from a gist. The multi-stage build is technically there but you have to read the whole Dockerfile to know which target ships. It works. You wouldn't want to explain it.
The commands are fine — it's the wiring around them that's a mess. Put the wiring in a task and the docker commands stay exactly what you'd type, just readable.
The build task
The image reference is computed once into an outputs: value, and every later step reads it back — so build, push, and report can't disagree about what they're shipping:
name: image
defaults:
registry: ghcr.io/acme
app: api
version: "1.0.0"
tasks:
# `orchstep run build --var version=2.4.0`
build:
desc: "Build, tag, and push a multi-stage image"
steps:
- name: compile
func: shell
do: echo "docker build --target build -t {{ vars.app }}:builder ."
- name: assemble
func: shell
do: echo "docker build --target runtime -t {{ vars.registry }}/{{ vars.app }}:{{ vars.version }} ."
outputs:
ref: "{{ vars.registry }}/{{ vars.app }}:{{ vars.version }}"
- name: push
func: shell
do: echo "docker push {{ steps.assemble.ref }}"
retry:
max_attempts: 3
interval: "1s"
backoff_rate: 2.0
- name: report
func: shell
do: echo "pushed {{ steps.assemble.ref }}"(The steps echo so the demo runs anywhere — swap the echos for real docker calls to make it live.)
One image ref, not three
The bug class this kills: assemble builds api:2.4.0 but push shoves api:2.4.1 because someone bumped one line and not the other. Here the ref is assembled once in steps.assemble.ref, and push and report both read that exact value. Change the version with --var version=2.4.0 and it propagates everywhere at once — there's a single source of truth for "what are we shipping."
The two-target build is right there in the open too. compile uses --target build, assemble uses --target runtime. You can see the multi-stage boundary at a glance instead of reverse-engineering it from the Dockerfile.
The retry is one block, not a loop
Registries flake. The usual answer is a hand-rolled bash until loop with a sleep $((2**n)) that nobody wants to touch. Here it's declarative — three attempts, starting at one second, doubling each time:
n=0
until [ $n -ge 3 ]; do
docker push "$REF" && break
n=$((n+1)); sleep $((2**n))
done
[ $n -ge 3 ] && { echo "push failed"; exit 1; }- name: push
func: shell
do: docker push {{ steps.assemble.ref }}
retry:
max_attempts: 3
interval: "1s"
backoff_rate: 2.0Same backoff behavior, but you read it as intent — "retry the push three times with exponential backoff" — instead of decoding arithmetic. And it's the same retry block you'd use on any flaky step, so you only learn it once.
What you actually gained
| Concern | Ad-hoc build script | OrchStep build task |
|---|---|---|
| Image ref | spelled out 3× | computed once into steps.assemble.ref |
| Multi-stage targets | buried in the Dockerfile | explicit --target per step |
| Flaky push | hand-rolled until loop | declarative retry: block |
| What got shipped | grep the logs | a report line with the ref |
| Changing the version | edit several lines | one --var version= |
Where OrchStep is not the answer
If you're doing heavy multi-arch matrix builds with layer caching across runners, lean on buildx and your CI's native build features — that's their job. This pattern is for the everyday single-image build-tag-push that lives in a script and has quietly grown a retry loop and three copies of the same tag. It makes that readable; it doesn't replace your build engine.
Where to go next
- Error Handling — retry, backoff, catch, and timeouts in full
- Variables & Outputs — computing a value once and reusing it
- shell — your existing
dockercommands as steps - Replace the bash script you keep rewriting — the same idea, applied to your whole deploy
Got a build script with a copy-pasted retry loop and three spellings of the image tag? Make it readable. Install the binary and start with the demo above.
curl -fsSL https://orchstep.dev/install.sh | sh