Multi-platform image with GitHub Actions

You can build multi-platform images using the platforms option, as shown in the following example:

Note
name: ci  on:  push:  jobs:  docker:  runs-on: ubuntu-latest  steps:  - name: Login to Docker Hub  uses: docker/login-action@v3  with:  username: ${{ vars.DOCKERHUB_USERNAME }}  password: ${{ secrets.DOCKERHUB_TOKEN }}   - name: Set up QEMU  uses: docker/setup-qemu-action@v3   - name: Set up Docker Buildx  uses: docker/setup-buildx-action@v3   - name: Build and push  uses: docker/build-push-action@v6  with:  platforms: linux/amd64,linux/arm64  push: true  tags: user/app:latest

Build and load multi-platform images

The default Docker setup for GitHub Actions runners does not support loading multi-platform images to the local image store of the runner after building them. To load a multi-platform image, you need to enable the containerd image store option for the Docker Engine.

There is no way to configure the default Docker setup in the GitHub Actions runners directly, but you can use docker/setup-docker-action to customize the Docker Engine and CLI settings for a job.

The following example workflow enables the containerd image store, builds a multi-platform image, and loads the results into the GitHub runner's local image store.

name: ci  on:  push:  jobs:  docker:  runs-on: ubuntu-latest  steps:  - name: Set up Docker  uses: docker/setup-docker-action@v4  with:  daemon-config: |  {  "debug": true,  "features": {  "containerd-snapshotter": true  }  }   - name: Login to Docker Hub  uses: docker/login-action@v3  with:  username: ${{ vars.DOCKERHUB_USERNAME }}  password: ${{ secrets.DOCKERHUB_TOKEN }}   - name: Set up QEMU  uses: docker/setup-qemu-action@v3   - name: Build and push  uses: docker/build-push-action@v6  with:  platforms: linux/amd64,linux/arm64  load: true  tags: user/app:latest

Distribute build across multiple runners

Building multiple platforms on the same runner can significantly extend build times, particularly when dealing with complex Dockerfiles or a high number of target platforms. By distributing platform-specific builds across multiple runners using a matrix strategy, you can drastically reduce build durations and streamline your CI pipeline. These examples demonstrate how to allocate each platform build to a dedicated runner, including ARM-native runners where applicable, and create a unified manifest list using the buildx imagetools create command.

The following workflow will build the image for each platform on a dedicated runner using a matrix strategy and push by digest. Then, the merge job will create manifest lists and push them to Docker Hub. The metadata action is used to set tags and labels.

name: ci  on:  push:  env:  REGISTRY_IMAGE: user/app  jobs:  build:  strategy:  fail-fast: false  matrix:  include:  - platform: linux/amd64  runner: ubuntu-latest  - platform: linux/arm64  runner: ubuntu-24.04-arm  runs-on: ${{ matrix.runner }}  steps:  - name: Prepare  run: |  platform=${{ matrix.platform }}  echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV   - name: Docker meta  id: meta  uses: docker/metadata-action@v5  with:  images: ${{ env.REGISTRY_IMAGE }}   - name: Login to Docker Hub  uses: docker/login-action@v3  with:  username: ${{ vars.DOCKERHUB_USERNAME }}  password: ${{ secrets.DOCKERHUB_TOKEN }}   - name: Set up QEMU  uses: docker/setup-qemu-action@v3   - name: Set up Docker Buildx  uses: docker/setup-buildx-action@v3   - name: Build and push by digest  id: build  uses: docker/build-push-action@v6  with:  platforms: ${{ matrix.platform }}  labels: ${{ steps.meta.outputs.labels }}  tags: ${{ env.REGISTRY_IMAGE }}  outputs: type=image,push-by-digest=true,name-canonical=true,push=true   - name: Export digest  run: |  mkdir -p ${{ runner.temp }}/digests  digest="${{ steps.build.outputs.digest }}"  touch "${{ runner.temp }}/digests/${digest#sha256:}"   - name: Upload digest  uses: actions/upload-artifact@v4  with:  name: digests-${{ env.PLATFORM_PAIR }}  path: ${{ runner.temp }}/digests/*  if-no-files-found: error  retention-days: 1   merge:  runs-on: ubuntu-latest  needs:  - build  steps:  - name: Download digests  uses: actions/download-artifact@v4  with:  path: ${{ runner.temp }}/digests  pattern: digests-*  merge-multiple: true   - name: Login to Docker Hub  uses: docker/login-action@v3  with:  username: ${{ vars.DOCKERHUB_USERNAME }}  password: ${{ secrets.DOCKERHUB_TOKEN }}   - name: Set up Docker Buildx  uses: docker/setup-buildx-action@v3   - name: Docker meta  id: meta  uses: docker/metadata-action@v5  with:  images: ${{ env.REGISTRY_IMAGE }}  tags: |  type=ref,event=branch  type=ref,event=pr  type=semver,pattern={{version}}  type=semver,pattern={{major}}.{{minor}}   - name: Create manifest list and push  working-directory: ${{ runner.temp }}/digests  run: |  docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \  $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)   - name: Inspect image  run: |  docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}

With Bake

It's also possible to build on multiple runners using Bake, with the bake action.

You can find a live example in this GitHub repository.

The following example achieves the same results as described in the previous section.

variable "DEFAULT_TAG" {  default = "app:local" }  // Special target: https://github.com/docker/metadata-action#bake-definition target "docker-metadata-action" {  tags = ["${DEFAULT_TAG}"] }  // Default target if none specified group "default" {  targets = ["image-local"] }  target "image" {  inherits = ["docker-metadata-action"] }  target "image-local" {  inherits = ["image"]  output = ["type=docker"] }  target "image-all" {  inherits = ["image"]  platforms = [  "linux/amd64",  "linux/arm/v6",  "linux/arm/v7",  "linux/arm64"  ] }
name: ci  on:  push:  env:  REGISTRY_IMAGE: user/app  jobs:  prepare:  runs-on: ubuntu-latest  outputs:  matrix: ${{ steps.platforms.outputs.matrix }}  steps:  - name: Checkout  uses: actions/checkout@v4   - name: Create matrix  id: platforms  run: |  echo "matrix=$(docker buildx bake image-all --print | jq -cr '.target."image-all".platforms')" >>${GITHUB_OUTPUT}   - name: Show matrix  run: |  echo ${{ steps.platforms.outputs.matrix }}   - name: Docker meta  id: meta  uses: docker/metadata-action@v5  with:  images: ${{ env.REGISTRY_IMAGE }}   - name: Rename meta bake definition file  run: |  mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json"   - name: Upload meta bake definition  uses: actions/upload-artifact@v4  with:  name: bake-meta  path: ${{ runner.temp }}/bake-meta.json  if-no-files-found: error  retention-days: 1   build:  needs:  - prepare  strategy:  fail-fast: false  matrix:  platform: ${{ fromJson(needs.prepare.outputs.matrix) }}  runs-on: ${{ startsWith(matrix.platform, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}  steps:  - name: Prepare  run: |  platform=${{ matrix.platform }}  echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV   - name: Download meta bake definition  uses: actions/download-artifact@v4  with:  name: bake-meta  path: ${{ runner.temp }}   - name: Login to Docker Hub  uses: docker/login-action@v3  with:  username: ${{ vars.DOCKERHUB_USERNAME }}  password: ${{ secrets.DOCKERHUB_TOKEN }}   - name: Set up Docker Buildx  uses: docker/setup-buildx-action@v3   - name: Build  id: bake  uses: docker/bake-action@v6  with:  files: |  ./docker-bake.hcl  cwd://${{ runner.temp }}/bake-meta.json  targets: image  set: |  *.tags=${{ env.REGISTRY_IMAGE }}  *.platform=${{ matrix.platform }}  *.output=type=image,push-by-digest=true,name-canonical=true,push=true   - name: Export digest  run: |  mkdir -p ${{ runner.temp }}/digests  digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"  touch "${{ runner.temp }}/digests/${digest#sha256:}"   - name: Upload digest  uses: actions/upload-artifact@v4  with:  name: digests-${{ env.PLATFORM_PAIR }}  path: ${{ runner.temp }}/digests/*  if-no-files-found: error  retention-days: 1   merge:  runs-on: ubuntu-latest  needs:  - build  steps:  - name: Download meta bake definition  uses: actions/download-artifact@v4  with:  name: bake-meta  path: ${{ runner.temp }}   - name: Download digests  uses: actions/download-artifact@v4  with:  path: ${{ runner.temp }}/digests  pattern: digests-*  merge-multiple: true   - name: Login to DockerHub  uses: docker/login-action@v3  with:  username: ${{ vars.DOCKERHUB_USERNAME }}  password: ${{ secrets.DOCKERHUB_TOKEN }}   - name: Set up Docker Buildx  uses: docker/setup-buildx-action@v3   - name: Create manifest list and push  working-directory: ${{ runner.temp }}/digests  run: |  docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("${{ env.REGISTRY_IMAGE }}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/bake-meta.json) \  $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)   - name: Inspect image  run: |  docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/bake-meta.json)