Publishing One Image to Several Registries

Build the image once, push it to a primary registry, then copy it by digest into every other target. The workflow YAML, the wiring, and the transfer math.

Push an image to several registries by building it once, pushing that build to one primary registry, and copying the resulting manifest by digest into each additional target. Every registry then serves the same bytes under the same sha256: digest, and the workflow pays for one build instead of one build per registry.

This guide covers the workflow shapes that quietly rebuild per target, the copy-by-digest fix, a workflow with one build job and a copy job per registry, and a transfer model that counts both runner minutes and bytes at a stated image size. It sits under Docker builds on GitHub Actions.

Diagnosis

A pipeline that publishes to three registries usually builds three times, and the YAML rarely makes that obvious. Three shapes account for most of it.

ShapeWhat you see in the workflowWhat it produces
A matrix over registriesstrategy.matrix.registry with a build and push step inside each entryOne full build per registry, and one distinct digest per registry
A second publish workflowAn on: release workflow with its own checkout and build step that targets the mirrorA build from a different point in time, with its own credential set
A promote scriptdocker pull, docker tag, docker push in a release jobA retag of whatever the source tag resolved to at pull time

Confirm it from the registries

Ask each registry what the release tag currently points at:

docker buildx imagetools inspect ghcr.io/acme/api:v1.4.0 \
  --format '{{.Manifest.Digest}}'

docker buildx imagetools inspect 111122223333.dkr.ecr.us-east-1.amazonaws.com/api:v1.4.0 \
  --format '{{.Manifest.Digest}}'

Two different sha256: values for one release means two builds happened. The image digest is computed over the manifest, which names the config and every layer by hash, so any difference at all in the output changes it.

Independent builds of one commit diverge for reasons that have nothing to do with your source. A base image tag that moved between the two runs, a package index that resolved a newer patch release, and a build timestamp written into a layer each produce different bytes from identical input. The result is that the image your scanner cleared and the image running in the second region are different artifacts that share a tag.

That divergence also breaks anything bound to a digest. Signatures, SBOMs, and provenance attestations attach to one manifest, so a per-registry rebuild leaves the attestation in the first registry describing an image that exists nowhere else. The container registry entry covers how registries store those references.

Confirm it from the bill

The Reports page settles the same question from the billing side. The Docker Builder billing table lists one row per session with profile, architecture, duration, and cost, so one merge that opened three sessions tells you the pipeline built three times. The CI billing table shows the same thing from the runner side, since each row is one job execution with its job name, runner label, and billed time, and every registry matrix entry carries a duration close to a full build rather than close to an upload.

Fix

Build once, push to a primary registry, and copy the manifest by digest into each remaining target.

docker buildx imagetools create is the copy. Given a source reference and one or more --tag values, it reads the source manifest, uploads any blobs the target registry does not already hold, and writes the same manifest bytes under the target name. Because the manifest is copied rather than regenerated, the digest survives the trip, and a multi-platform image index copies as one unit with all of its per-platform manifests intact.

Two properties of that copy decide the cost model later. The target registry is asked whether it already holds each blob before anything is uploaded, so a release that changes one application layer moves one layer rather than the whole image. And a copy between two repositories inside the same registry can mount the existing blobs instead of transferring them, while a copy across registry hosts moves the blob bytes through the job doing the copy.

The two job roles size differently. Rates below come from the cloud runners documentation and the pricing page, checked on 2026-08-13.

JobRunner labelvCPURAMStorageRate per minute
Build and push to the primary registrywarp-ubuntu-latest-x64-8x832GB150GB SSD$0.016
Copy to each additional registrywarp-ubuntu-latest-x64-2x28GB150GB SSD$0.004

The copy job spends its minutes on network round trips and blob uploads, so cores buy nothing there. The build job takes the larger label because that is where the compile happens. A workflow that also publishes Windows images can point the same pattern at a Windows label.

The one-step alternative

A buildx push accepts several tags in different registries and pushes the same result to each. That is fewer moving parts, and it is worth taking when all the targets are equally reliable and every credential belongs in the build job anyway. The trade is coupling. A registry that rejects the push or times out fails the whole build after the image has already been produced, and rerunning the job rebuilds. A copy job per registry can be rerun on its own, and fail-fast: false keeps one slow mirror from cancelling the others.

Configuration

The workflow below builds once on a tag push, publishes to GitHub Container Registry, and fans out to two more registries with one copy job each.

name: publish
on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  packages: write
  id-token: write

env:
  PRIMARY: ghcr.io/acme/api
  VERSION: ${{ github.ref_name }}

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Log in to the primary registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: build
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.PRIMARY }}:${{ env.VERSION }}
          profile-name: platform-amd64
          timeout: 600000

  copy:
    needs: [build]
    runs-on: warp-ubuntu-latest-x64-2x
    strategy:
      fail-fast: false
      matrix:
        include:
          - name: ecr
            target: 111122223333.dkr.ecr.us-east-1.amazonaws.com/api
          - name: mirror
            target: registry.example.com/acme/api
    steps:
      - name: Log in to the source registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Exchange the OIDC token for AWS credentials
        if: matrix.name == 'ecr'
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/github-actions-ecr-push
          aws-region: us-east-1

      - name: Log in to Amazon ECR
        if: matrix.name == 'ecr'
        uses: aws-actions/amazon-ecr-login@v2

      - name: Log in to the mirror
        if: matrix.name == 'mirror'
        uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.MIRROR_USERNAME }}
          password: ${{ secrets.MIRROR_TOKEN }}

      - name: Copy by digest
        run: |
          docker buildx imagetools create \
            --tag ${{ matrix.target }}:${{ env.VERSION }} \
            ${{ env.PRIMARY }}@${{ needs.build.outputs.digest }}

      - name: Verify the digest survived the copy
        run: |
          src="${{ needs.build.outputs.digest }}"
          dst=$(docker buildx imagetools inspect \
            ${{ matrix.target }}:${{ env.VERSION }} \
            --format '{{.Manifest.Digest}}')
          if [ "$src" != "$dst" ]; then
            echo "digest mismatch: $src against $dst" >&2
            exit 1
          fi

The wiring, line by line

WhereLineWhy it is required
Push stepid: buildsteps.build.outputs.digest needs a step id to reference
Build joboutputs.digestJob outputs are the only channel between jobs on GitHub Actions (workflow syntax)
Push steppush: trueThe digest output comes back from the registry push, so a build that stays local reports nothing
Copy jobneeds: [build]The needs context exposes outputs only for the jobs named there
Copy step${{ env.PRIMARY }}@${{ needs.build.outputs.digest }}Copying from a digest reference rather than a tag, so a later push to the tag cannot change what is copied
Copy jobTwo login stepsThe copy pulls from the source host and pushes to the target host, and Docker stores one credential entry per host
Copy jobfail-fast: falseOne failing target leaves the other copies to finish and be rerun independently

Credentials per target

Each registry brings its own login shape. GitHub Container Registry takes the per-job GITHUB_TOKEN with packages: write in the permissions block. A cloud registry takes an OpenID Connect exchange, which needs id-token: write and a trust policy on the cloud side naming one repository (security hardening with OpenID Connect). A self-managed registry takes a username and token from secrets. The private registry authentication answer covers all three in detail.

WarpBuild does not access or store build secrets, and each runner is its own virtual machine that is destroyed after the build, so the Docker config those login steps wrote goes away with it (security documentation).

Failures worth recognizing

denied or 403 on the copy, with the pull working. The credential authenticated and the scope it received has no push right on the target repository. On a cloud registry this is usually a repository policy or an IAM role without ecr:PutImage.

manifest unknown naming the source. The digest output was empty or the build never pushed, so the copy asked for a reference ending in @. Guard the copy job with a step that checks the value starts with sha256: before it runs.

An unsupported media type error on one target only. Buildx attaches provenance and SBOM attestations to the index by default, and a registry that rejects those manifest types fails the copy while accepting a plain image. Building with provenance disabled, or copying the platform manifests alone, gets the release out while the registry is upgraded.

The target repository does not exist. Some registries create a repository on first push and others require it to exist first, which turns into a name unknown error on the very first release to a new target.

CI observability is the useful one while tuning a fan-out, since it correlates runner system metrics with GitHub Actions job logs and shows whether a copy job is waiting on network or on the registry.

Cost or Time Model

Everything below is arithmetic on stated assumptions rather than a measurement. Substitute the durations in your own job logs and the compressed size from docker buildx imagetools inspect --raw before deciding anything on it.

Assumptions:

  • Three registries: a primary and two additional targets.
  • The image is 1.2 GB compressed, of which 0.4 GB comes from base image layers pulled during the build and 0.08 GB is the application layer that changes each release.
  • A cold build occupies a runner for 6.0 minutes, including the push to the primary registry.
  • A copy job occupies a runner for 1.5 minutes.
  • 40 tagged releases per month.
  • Rates from the pricing page, checked on 2026-08-13: warp-ubuntu-latest-x64-8x at $0.016 per minute, warp-ubuntu-latest-x64-2x at $0.004 per minute.

Runner cost per release:

PathArithmeticRunner minutesCost per releaseCost at 40 releases
Build in each registry job3 x 6.0 x $0.01618.0$0.288$11.52
Build once, copy twice(6.0 x $0.016) + (2 x 1.5 x $0.004)9.0$0.108$4.32

The copy path removes 9.0 runner minutes and $0.180 per release, which comes to 360 minutes and $7.20 per month for one service. Those are small numbers for one repository and they scale linearly with services and with registries, since each extra registry adds a full build on the first path and 1.5 minutes on the second.

Bytes move differently, and the honest version of the table has three rows:

PathBytes into the runnersBytes out of the runnersDigests across the three registries
Build in each registry job1.2 GB, three base pulls3.6 GB, three full pushesThree different values
Build once, copy twice, targets empty2.8 GB, one base pull plus two manifest reads3.6 GB, one push plus two copiesOne value
Build once, copy twice, targets already holding the base layers0.56 GB0.24 GBOne value

Read those rows in order. On a first release to empty targets, copying moves more bytes than rebuilding, because the blobs travel to the primary registry and then back out through each copy job. On every release after that, the blob existence check ahead of each upload means only the 0.08 GB application layer moves, and the copy path drops well below the rebuild path on both counts.

The rebuild path cannot reach that third row reliably. Three independent builds produce three different layer digests unless the build is bit for bit reproducible, so the target registries have nothing to deduplicate against and keep paying the full transfer every release.

Wall clock runs the other way and is worth stating plainly. Three builds in parallel finish in 6.0 minutes, while build-then-copy finishes in 7.5 minutes because the copy jobs wait on the build. The trade is 1.5 minutes of pipeline latency for 9.0 runner minutes, one large runner held during the release instead of three, and one digest instead of three.

Two variations change the arithmetic. If the build runs on a remote Docker builder, the build minutes move to a builder session billed at $0.06 per minute for the 16 vCPU, 32GB profile (Docker builders documentation, checked on 2026-08-13), which widens the gap further, since the rebuild path opens one session per registry. And if two of the three targets are repositories inside the same registry host, the copy between them can mount existing blobs rather than transfer them, so those bytes leave the table entirely.

Where the runners pull from matters for the deployment side of the same pipeline. On the enterprise tier, egress costs from your cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners; the zero egress page covers the terms.

Once one digest reaches every registry, the deploy side stops guessing which build it is running, which is the subject of build once, deploy many on GitHub Actions.

FAQ

Does copying an image between registries change its digest?

No, as long as the copy moves the manifest bytes unchanged. A digest is the SHA-256 hash of the manifest document, and docker buildx imagetools create uploads the blobs that manifest references and then writes the same manifest under the target name. The tag differs per registry and the digest is identical, which is what makes the copy verifiable with a single comparison step.

Can one build and push step publish to several registries at once?

Yes. The tags input on a buildx push accepts references in different registries, and the builder pushes the same result to each one. The cost is coupling: every credential has to be present in the build job, and a registry that is unreachable or rejects the push fails the build after the image was already produced. A separate copy job per registry can be retried on its own.

Do I need to log in to both registries in the copy job?

Yes. The copy reads the manifest and any missing blobs from the source registry and writes them to the target, so the job needs a pull credential for the source and a push credential for the target. Two login steps in the same job is the normal shape, because the Docker config stores one entry per registry host.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.