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:
- Github Action: https://github.com/alexmihai1984/cloudflare-ddns/blob/main/.github/workflows/release.yml
- Dockerfile: https://github.com/alexmihai1984/cloudflare-ddns/blob/main/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.
Leave a Reply