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

In the previous example, each platform is built on the same runner which can take a long time depending on the number of platforms and your Dockerfile.

To solve this issue you can use a matrix strategy to distribute the build for each platform across multiple runners and create 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:     runs-on: ubuntu-latest     strategy:       fail-fast: false       matrix:         platform:           - linux/amd64           - linux/arm64     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:     runs-on: ubuntu-latest     needs:       - prepare     strategy:       fail-fast: false       matrix:         platform: ${{ fromJson(needs.prepare.outputs.matrix) }}     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 QEMU         uses: docker/setup-qemu-action@v3        - 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)