BLOG/DEVELOPER DAILY
THEME
DEVELOPER DAILY

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.

Jun 18, 2026 OrchStep Team 6 minROLE: Backend DeveloperSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/docker-builds
VIEW SOURCE

Every 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:

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

BEFORE — BASH RETRY LOOP
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; }
AFTER — RETRY AS SYNTAX
- name: push
  func: shell
  do: docker push {{ steps.assemble.ref }}
  retry:
    max_attempts: 3
    interval: "1s"
    backoff_rate: 2.0

Same 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

ConcernAd-hoc build scriptOrchStep build task
Image refspelled out 3×computed once into steps.assemble.ref
Multi-stage targetsburied in the Dockerfileexplicit --target per step
Flaky pushhand-rolled until loopdeclarative retry: block
What got shippedgrep the logsa report line with the ref
Changing the versionedit several linesone --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

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.

#DOCKER#CI#RETRY#TASK-RUNNER
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — DEVELOPER DAILY