Hello there!

It is a short article about how to build multi-arch docker images in GitHub Actions. For example, you have the following file (the default Dockerfile, generated by IDE):

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
 
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["WebApplication1/WebApplication1.csproj", "WebApplication1/"]
RUN dotnet restore "WebApplication1/WebApplication1.csproj"
COPY . .
WORKDIR "/src/WebApplication1"
RUN dotnet build "WebApplication1.csproj" -c $BUILD_CONFIGURATION -o /app/build
 
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "WebApplication1.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
 
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebApplication1.dll"]

and want to build two images. One for linux/amd64 and another one for linux/arm64, then you need to follow the documentation of setup-buildx-action. You need to install QEMU and Buildx and you will be able to build multi-arch images. For example, here is the workflow yaml file:

name: Build Docker Image CI
 
on:
  # ...
 
jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
    # ...
 
    - name: Set up QEMU
      uses: docker/setup-qemu-action@v3
      
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3
 
    - name: Login to GitHub Container Registry
      uses: docker/login-action@v3
      with:
        registry: # ...
        username: # ...
        password: # ...
 
    - name: Docker Metadata
      id: meta
      uses: docker/metadata-action@v5
      with:
        images: # ...
 
    - name: Build and push
      uses: docker/build-push-action@v6
      with:
        platforms: linux/amd64,linux/arm64
        context: .
        push: ${{ github.event_name != 'pull_request' }}
        tags: ${{ steps.meta.outputs.tags }}
        cache-from: type=gha
        cache-to: type=gha,mode=max

Important parts of this yaml file are the setup of QEMU and the platrorms options in the Build and push step. After these changes, GitHub Actions should build two images for specified platforms but unfortunately, it doesn’t work for .NET. It will throw you an error:

Error: buildx failed with: ERROR: failed to solve: process “/bin/sh -c dotnet restore “WebApplication1.csproj"" did not complete successfully: exit code: 135

To fix it, you need to specify one parameter in your Dockerfile: --platform=$BUILDPLATFORM, so updated Dockerfile will look like:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
 
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY ["WebApplication1/WebApplication1.csproj", "WebApplication1/"]
RUN dotnet restore "WebApplication1/WebApplication1.csproj" --runtime $TARGETOS-$TARGETARCH
COPY . .
WORKDIR "/src/WebApplication1"
RUN dotnet build "WebApplication1.csproj" -c $BUILD_CONFIGURATION -o /app/build
 
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "WebApplication1.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
 
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WebApplication1.dll"]

BUILDPLATFORM, TARGETOS, TARGETARCH are built-in parameters, and --platform=$BUILDPLATFORM pins a stage to host architecture. So, the build stage will always be amd64 (because GitHub Actions runner is amd64) whereas all other images will be amd64 or arm64 (depends what image you are building). It helps to speed up a build process because .NET compiler supports cross-compilations and you can create one executable (IL binary) and run it on different platforms/runtimes. Also, there is an important part - --runtime $TARGETOS-$TARGETARCH in dotnet restore. It is needed to restore the “right” versions of packages, especially when your application relies on native libraries, for example, SQLite. But after dotnet restore, we can use our default approach to build and publish the application. Copy it to the final image because it doesn’t have --platform=$BUILDPLATFORM it will have an appropriate runtime for the current architecture.

Also, you need to keep in mind that this approach relies on the cross-platform nature of .NET and IL, if you need to publish a self-contained application or native, then you might need to remove --platform=$BUILDPLATFORM to form Docker builder to build a new image for each platform.