I had a freshly-built, multi-arch dev image (linux/amd64 + linux/arm64) and one job: promote it into a private partner repo on Docker Hub, plus stamp a dated tag so :latest is always traceable back to a real build. Cross-repo. Should be five minutes.
It was not five minutes.
The problem
The obvious tool is docker buildx imagetools create... it's built for copying manifests between tags. So I reached for it. And it sat there. Then it 400'd. Then I retried, and it hung. Cross-repo blob copies on Docker Hub are reproducibly flaky with imagetools, and I burned the better part of an hour confirming that before I went looking for something else.
The fallback most people reach for next is worse: pull the image down, retag it, push it to the new repo. That round-trips the entire image — every layer, every arch - through your laptop's daemon and disk, only to push the same bits back up. And if you're not careful with how you tag, you flatten a multi-arch index down to whatever single arch your machine happens to be. No thanks.
Why crane
crane (from Google's go-containerregistry) does the copy registry-to-registry. It never pulls the image to your machine.
-
It preserves the multi-arch manifest.
crane cpcopies the whole image index by digest. Both arches come along. Nothing gets flattened. - It actually works cross-repo on Docker Hub. Where imagetools 400'd and hung, crane did the server-to-server copy in seconds.
- No daemon, no disk. It talks to the registries directly. Your laptop just orchestrates.
flowchart LR
subgraph slow["pull → tag → push"]
A[source repo<br/>you/app:dev] --> L[your daemon + disk<br/>whole image] --> B[partner repo<br/>you/partner-app:latest]
end
subgraph crane["crane cp"]
C[source repo<br/>you/app:dev] -->|manifest + blobs<br/>by digest| D[partner repo<br/>you/partner-app:latest]
end
Run it as a container, no install
docker run --rm gcr.io/go-containerregistry/crane:debug <crane-args>
That's the whole installation story. Nothing on the host.
Auth issues (MacOS)
crane in the container reads ~/.docker/config.json. On macOS with Docker Desktop, your login isn't in that file — it's credsStore: osxkeychain, sitting in the macOS keychain. So if you naively mount ~/.docker/config.json into the container, crane sees no usable credential and hands you UNAUTHORIZED.
The fix: pull the credential out of the keychain on the host, write it into a temporary inline config, mount that, and delete it the moment you're done. Never print it, never commit it.
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
cat > "$TMP/mkcfg.py" <<'PY'
import json, sys, base64
d = json.load(sys.stdin)
auth = base64.b64encode((d["Username"] + ":" + d["Secret"]).encode()).decode()
json.dump({"auths": {"https://index.docker.io/v1/": {"auth": auth}}}, open(sys.argv[1], "w"))
PY
echo "https://index.docker.io/v1/" | docker-credential-osxkeychain get | python3 "$TMP/mkcfg.py" "$TMP/config.json"
CRANE() { docker run --rm -v "$TMP/config.json":/root/.docker/config.json:ro \
gcr.io/go-containerregistry/crane:debug "$@"; }
The trap ... EXIT cleans up the temp config when your shell exits, so the credential doesn't linger.
On Linux (or anywhere docker login writes inline creds), skip all of that and mount ~/.docker/config.json directly. And obvious-but-worth-saying: you need a read/write login on the destination. A read-only token can't push.
Promote the image
# copy SRC -> DST, full multi-arch manifest, server to server:
CRANE cp you/app:dev \
you/partner-app:latest
# also stamp a dated, immutable tag so :latest is always traceable to a build:
CRANE cp you/app:dev \
you/partner-app:dev-2026-06-11
Two copies, both registry-side, both done before you can refill your coffee.
Verification
Don't trust that the copy worked. Prove it. The destination digest must equal the source digest — same bits, same manifest:
CRANE digest you/app:dev # source
CRANE digest you/partner-app:latest # must equal source
CRANE digest you/partner-app:dev-2026-06-11 # must equal source
If those three lines match, the right image landed under both tags. If they don't, you copied the wrong thing — better to find out here than in a partner's deployment.
Bonus
CRANE ls you/partner-app # list tags
CRANE manifest you/app:dev # full manifest JSON, see the arches
CRANE tag you/app@sha256:<digest> newtag # add a tag to an existing digest
CRANE copy <SRC> <DST> # alias of cp
One caveat worth knowing: tag deletion isn't a crane operation. Use the Docker Hub UI or the API for that (and it needs an admin-scoped token — a read/write PAT won't delete).
TL;DR
Reach for crane any time you're moving an image between registries or repos and you care about the manifest arriving intact — promotions, mirrors, cross-org handoffs. It skips the daemon, skips the disk, and it doesn't fall over on Docker Hub cross-repo copies the way imagetools does. And whatever you do, build the crane digest source-equals-dest check into the workflow. It costs two seconds.

Top comments (0)