Build multi-architecture Docker images for GraalVM in Github Actions

,

In a previous post I explained that I gave GraalVM native images a try for a simple Spring boot app (my Cloudflare DDNS) and I wasn’t too thrilled about the experience.

I mentioned how one of the issues was that if you want to target different architectures with your application, you have to build the binary for each platform you are targeting and then build the Docker image that conditionally contains the correct native image for each architecture. The issue is that the build process is very slow because of the emulation, but this is not about that.

This post is meant just to show how to achieve that, by looking at an example, namely the Cloudflare DDNS project I already mentioned.

For the full reference, here are the links to the actual Github Actions definition and Dockerfile:


Now let’s just extract the most important part here and explain it.

...
      - name: Docker login
        uses: docker/login-action@v3
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Docker set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Docker set up buildx
        uses: docker/setup-buildx-action@v3

      - name: Build native image amd64
        run: |
          docker run --rm --platform linux/amd64 \
            -v "$PWD:/app" -w /app \
            vegardit/graalvm-maven:latest-java21 \
            mvn -Pnative clean package native:compile-no-fork -Dos.arch=amd64
          ls -al ./target/cloudflare-ddns-amd64
          cp ./target/cloudflare-ddns-amd64 ./cloudflare-ddns-amd64

      - name: Build native image arm64
        run: |
          docker run --rm --platform linux/arm64 \
            -v "$PWD:/app" -w /app \
            vegardit/graalvm-maven:latest-java21 \
            mvn -Pnative clean package native:compile-no-fork -Dos.arch=arm64
          ls -al ./target/cloudflare-ddns-arm64
          cp ./target/cloudflare-ddns-arm64 ./cloudflare-ddns-arm64

      - name: Docker build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: alexmihai1984/cloudflare-ddns:latest,alexmihai1984/cloudflare-ddns:${{ env.VERSION }}
          platforms: linux/amd64,linux/arm64

Step Docker login just performs the login into the Docker registry, in this case DockerHub using the provided username and password params.

Step Docker set up QEMU installs QEMU, which is the emulator that will be used to run the build on different architectures. For instance, if you look at the line further down, where we perform docker run --rm --platform linux/arm64, it would have failed without QEMU installed.

Step Docker set up buildx installs Docker buildx, which is needed to run multi-architecture Docker builds.

The steps Build native image amd64 and Build native image arm64 do the same thing for the two different architectures, amd64 and arm64. Each of them runs the maven build command inside a graalvm-maven Docker image for the correct architecture (note the --platform linux/amd64 and --platform linux/arm64 params). The Build native image amd64 step will result in an executable file called cloudflare-ddns-amd64 in the root of the project. Similarly, Build native image arm64 will result in one called cloudflare-ddns-arm64.

The last step, Docker build and push, does exactly what the name suggests, builds the docker image and pushes it to DockerHub. The platforms parameter dictates the architectures for which the Docker image should be built, in this case amd64 and arm64. The most important moving part here is the Dockerfile, so let’s take a look at that:

# amd64
FROM ubuntu:25.04 AS build-amd64
COPY ./cloudflare-ddns-amd64 ./cloudflare-ddns

# arm64
FROM ubuntu:25.04 AS build-arm64
COPY ./cloudflare-ddns-arm64 ./cloudflare-ddns

# common
FROM build-${TARGETARCH} AS build
ENTRYPOINT ["./cloudflare-ddns", "-Xmx128M"]

It defines two separate build stages, one specific to amd64 named build-amd64 and one for arm64 named build-arm64. Each of them copy the correct binary file for their architecture to the location ./cloudflare-ddns.

For the common part, ${TARGETARCH} gets resolved to either amd64 or arm64, matching the second half of the string given in platforms: linux/amd64,linux/arm64. This way it will pick the correct previous stage. So when it’s called for linux/amd64 it will copy ./cloudflare-ddns-amd64 to ./cloudflare-ddns. Similarly for arm64. So the entrypoint will be correct for both architectures, because the right file will be at that location.

Let me show you what this produces in DockerHub:

Hope this helps, have fun clickity-clacking.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *