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
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.
What you get
For each run, the action publishes:
- one tag per platform:
:<tag>-amd64and:<tag>-arm64by 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/v7That 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:latestHow 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-slimYou 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.
