All guides

Publish multi-arch base images to any registry with one GitHub Action

multiarch-image-pusher copies a base image into your own registry as an amd64/arm64 manifest, optionally with its OS packages updated first. Works with ECR, GHCR, GitLab, Docker Hub and others.

  • v1.1.3

5 min readUpdated November 8, 2026

  • Docker
  • GitHub Actions
  • Containers
  • Multi-arch
  • ARM64

Most teams build on top of public base images like python:3.12-slim or amazon/aws-lambda-python:3.12. Pulling them straight from Docker Hub in every build has drawbacks: rate limits, an outside dependency in every pipeline, and no control over when the base changes. And with Apple Silicon laptops and Graviton servers, you need both amd64 and arm64 images.

multiarch-image-pusher is a GitHub Action that copies a base image into your registry as a multi-arch image, in one step. It can also rebuild it with the latest OS security updates on the way.

multiarch-image-pusher One base image in, per-architecture tags and a combined manifest out

What you get

For each run, the action publishes:

  • one tag per platform: :<tag>-amd64 and :<tag>-arm64 by default;
  • the combined :<tag>, a manifest list that points to all of them.

When someone runs docker pull my-registry/python-base:3.12-slim, Docker picks the right architecture from the manifest list automatically.

It works with any registry: Amazon ECR, GitHub Container Registry, GitLab Container Registry, Docker Hub, or a generic registry.

Two modes

update-libraries What happens Use it when
false (default) The image is copied as-is, registry to registry You want a mirror of the exact upstream image
true Each platform is rebuilt with its OS packages updated, then pushed You want the latest security fixes without waiting for upstream

When updating, the action detects the package manager in the image (dnf, microdnf, yum, apt-get, apk or zypper), so the same step works for Debian, Ubuntu, Alpine, Amazon Linux, RHEL-family and SUSE images. If it can't find one, it fails with a clear error instead of skipping the update.

Inputs

Input Required Default
base-image yes Image to copy, e.g. python:3.12-slim. The runner must be able to pull it
dest-registry yes Registry hostname, e.g. ghcr.io
dest-image-repo yes Repository in that registry, e.g. my-org/python-base
dest-image-tag no latest Tag to publish
update-libraries no false true to rebuild with OS packages updated
platforms no linux/amd64,linux/arm64 Comma-separated buildx platforms
buildkit-image no Custom BuildKit image (e.g. a private mirror), used when update-libraries is true

The action doesn't log in to any registry. Add a login step before it for the destination, and for the source too if it's private. That keeps your credentials in your own workflow.

Example: copy to GHCR as-is

name: Publish base image

on:
  workflow_dispatch:

permissions:
  contents: read
  packages: write

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - name: Log in to GHCR
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username ${{ github.actor }} --password-stdin

      - uses: marcdomain/multiarch-image-pusher@v1
        with:
          base-image: python:3.12-slim
          dest-registry: ghcr.io
          dest-image-repo: my-org/python-base
          dest-image-tag: '3.12-slim'

This publishes ghcr.io/my-org/python-base:3.12-slim-amd64, :3.12-slim-arm64, and the combined :3.12-slim.

Example: ECR, with OS packages updated, every week

Use GitHub's OIDC to assume an AWS role (no long-lived keys), and a schedule so the image picks up security fixes regularly:

name: Refresh Lambda base image

on:
  schedule:
    - cron: "0 5 * * 1"   # Mondays at 05:00 UTC
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push
          aws-region: eu-west-1

      - name: Log in to Amazon ECR
        run: aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-west-1.amazonaws.com

      - uses: marcdomain/multiarch-image-pusher@v1
        with:
          base-image: amazon/aws-lambda-python:3.12
          dest-registry: 123456789012.dkr.ecr.eu-west-1.amazonaws.com
          dest-image-repo: aws-lambda
          dest-image-tag: '3.12'
          update-libraries: 'true'

Your Dockerfiles then build FROM 123456789012.dkr.ecr.eu-west-1.amazonaws.com/aws-lambda:3.12.

Example: Docker Hub

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - name: Log in to Docker Hub
        run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login docker.io --username ${{ secrets.DOCKERHUB_USERNAME }} --password-stdin

      - uses: marcdomain/multiarch-image-pusher@v1
        with:
          base-image: alpine:3.20
          dest-registry: docker.io
          dest-image-repo: my-org/alpine-base
          dest-image-tag: '3.20'

Example: more platforms

Add any platform the base image publishes, using buildx syntax. For example, 32-bit ARM for Raspberry Pi-class hardware:

      - uses: marcdomain/multiarch-image-pusher@v1
        with:
          base-image: node:20-bullseye
          dest-registry: registry.gitlab.com
          dest-image-repo: my-group/my-project/node-base
          dest-image-tag: '20-bullseye'
          platforms: linux/amd64,linux/arm64,linux/arm/v7

That adds a :20-bullseye-arm-v7 tag to the manifest.

Example: private source image

Log in to both registries first:

    steps:
      - name: Log in to source registry
        run: echo "${{ secrets.SOURCE_REGISTRY_TOKEN }}" | docker login some-private-registry.example.com --username ci --password-stdin

      - name: Log in to destination registry
        run: echo "${{ secrets.DEST_REGISTRY_TOKEN }}" | docker login registry.gitlab.com --username ci --password-stdin

      - uses: marcdomain/multiarch-image-pusher@v1
        with:
          base-image: some-private-registry.example.com/team/base:1.0
          dest-registry: registry.gitlab.com
          dest-image-repo: my-group/my-project/node-base
          dest-image-tag: '1.0'

Example: BuildKit from a private mirror

Rebuilding (update-libraries: 'true') uses BuildKit, which normally comes from Docker Hub. On rate-limited or locked-down runners, point it at your own copy:

      - uses: marcdomain/multiarch-image-pusher@v1
        with:
          base-image: amazon/aws-lambda-python:3.12
          dest-registry: 123456789012.dkr.ecr.eu-west-1.amazonaws.com
          dest-image-repo: aws-lambda
          update-libraries: 'true'
          buildkit-image: 123456789012.dkr.ecr.eu-west-1.amazonaws.com/mirror/buildkit:latest

How it works

Copy as-is (update-libraries: 'false'): for each platform, the action reads the base image's manifest list, finds the entry for that architecture, and copies it to :<tag>-<arch> with docker buildx imagetools create. This happens registry to registry, without pulling layers onto the runner, so it's fast. Then it creates the combined :<tag> from those tags.

A detail that's easy to get wrong: linux/arm64 in buildx syntax doesn't name a variant, but registries record it as arm64/v8 (and linux/arm as arm/v7). The action fills in those defaults so the lookup matches.

Rebuild with updates (update-libraries: 'true'): the action sets up QEMU and buildx, writes a small Dockerfile per platform that runs the detected package manager's update command (for example apt-get update && apt-get upgrade -y or apk upgrade), cleans the package cache, builds and pushes each platform, then creates and pushes the manifest list with docker manifest. For Amazon Linux, it also points the release version at latest so updates come from the newest repository.

Check the result

docker buildx imagetools inspect ghcr.io/my-org/python-base:3.12-slim

You should see one entry per platform, for example linux/amd64 and linux/arm64.

Good to know

  • The base image must already publish every platform you ask for. If one is missing, the copy fails with No manifest entry for platform … found in ….
  • Rebuilding under QEMU emulation is slower than copying, especially for non-native platforms. Copying is the default for that reason.
  • Pin the action to a release tag (@v1, or a specific one like @v1.1.3) in production workflows.

Marcus Chukwuoma

DevOps / Software Engineer. I write practical guides on infrastructure, CI/CD and testing.