Docker Multi-architecture Image Builds
Table of Contents
Background
When building and pushing multi-architecture Docker images, you need to be very careful about pushing these images to the registry.
When you push a Docker image to a registry, what happens is that each layer in the image is uploaded as a blob. After every layer is uploaded, the manifest is then updated. The manifest is basically just a JSON file that tells consumer what tags correspond to what blobs.
Pseudocode example:
1{
2 "tags": {
3 "latest": [
4 {
5 "platform": "amd64",
6 "blobs": ["sha256:abcdef"]
7 }
8 ],
9 "2.1.0": [
10 {
11 "platform": "amd64",
12 "blobs": ["sha256:abcdef"]
13 }
14 ]
15 }
16}
So once the layers are finished uploading, the manifest is updated to either add a new tag, or change the existing tag. The thing to be careful of here is that whenever a push is made for an existing tag, the entire contents are overwritten.
Problem
What this means that if you create a multi-architecture build on a single server, everything will be just fine.
1{
2 "tags": {
3 "latest": [
4 {
5 "platform": "amd64",
6 "blobs": [
7 "sha256:abcdef"
8 ]
9 }
10 {
11 "platform": "arm64/v7",
12 "blobs": [
13 "sha256:123456"
14 ]
15 }
16 ]
17 }
18}
However, if you split the build across different servers (maybe to take advantage of building an ARM image on an ARM server), whatever build finishes last will be the one overwrites the registry manifest last and will effectively be the only copy available in the registry.
1{
2 "tags": {
3 "latest": [
4 {
5 "platform": "arm64/v7",
6 "blobs": ["sha256:123456"]
7 }
8 ]
9 }
10}
Solution
Building
First, when building the image with
docker buildx build,
set the following options in the
output section:
type=image: Export to an imagepush=true: Still push the image to the registrypush-by-digest=true: However, only push the layers, don’t update the manifestname-canonical=true: Add an additional namename@digest. See below.
Additionally, only tag the image with its base name. By this, I mean use
--tag docker.io/library/python not --tag docker.io/library/python:3.14.1.
Manifest Update
Now that the image is pushed without the tags, the manifest needs to be updated.
Thankfully, this can be done with the
docker buildx imagetools create
command. The documentation for this command isn’t super obvious, but it takes an image
name and a list of tags to assign to it. The --append flag allows it to add these
layers to a tag, instead of replacing it. For example:
1docker buildx imagetools create --append -t docker.io/library/python:3.14.1 -t docker.io/library/python:3.14 -t docker.io/library/python:3 docker.io/library/python@sha256:abcdef
By adding the name-canonical=true earlier, you can reference the image you want by the SHA256 digest.
Do note that you can only run this command for a single registry. If your image is
pushed to more than one registry, then you will need to run this command multiple times.
Conclusion
Hopefully this helps when publishing multi-architecture images with CI/CD. Below is an abbreviated example I’ve made for one of my images using GitHub Actions. I think this is simpler and has less magic than the official Docker example:
1name: Build and Push Images
2
3jobs:
4 bake:
5 runs-on: ubuntu-latest
6
7 permissions:
8 contents: read
9
10 steps:
11 - name: Checkout Code
12 uses: actions/checkout@v6
13
14 # Left as an excerise to the reader, but in my case this is a Python
15 # script that generates a Bake file
16 # https://docs.docker.com/build/bake/reference/
17 - name: Create Bake File
18 run: python dev/baker.py
19
20 # Upload the generated Bake file so it can be used across jobs
21 - name: Upload Bake File
22 uses: actions/upload-artifact@v6
23 with:
24 path: docker-bake.json
25 name: bake-file
26
27 # Splits the Bake file into a build matrix based on the platforms
28 - name: Generate Build Matrix
29 id: generate
30 uses: docker/bake-action/subaction/matrix@v6
31 with:
32 # Name of the target in the Bakefile
33 target: webtrees
34 fields: platforms
35
36 # This is another exercise left to the reader, but this step
37 # outputs a string with tags that are going to be built prefaced by "-t",
38 # so it can be piped in to the command later to update the tags in the
39 # registry after the image is built.
40 - name: Output Metadata
41 id: metadata
42 run: python dev/metadata.py
43
44 # Save outputs
45 outputs:
46 build-matrix: ${{ steps.generate.outputs.matrix }}
47 metadata: ${{ steps.metadata.outputs.metadata }}
48
49 build:
50 needs: bake
51 # Select an ARM runner for ARM platforms
52 runs-on: ${{ startsWith(matrix.platforms, 'linux/arm') && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
53
54 strategy:
55 matrix:
56 # Include the matrix from the previous step
57 include: ${{ fromJson(needs.bake.outputs.build-matrix) }}
58
59 permissions:
60 contents: read
61 packages: write
62
63 steps:
64 - name: Checkout Code
65 uses: actions/checkout@v6
66
67 # Download the Bake file so it can be used to build
68 - name: Download Bake File
69 uses: actions/download-artifact@v7
70 with:
71 name: bake-file
72
73 - name: Set up Docker Buildx
74 uses: docker/setup-buildx-action@v3
75 with:
76 version: latest
77
78 # Login to the registries we will be pushing to
79 - name: DockerHub Login
80 uses: docker/login-action@v3
81 with:
82 username: ${{ secrets.DOCKERHUB_USERNAME }}
83 password: ${{ secrets.DOCKERHUB_PASSWORD }}
84
85 - name: Github CR Login
86 uses: docker/login-action@v3
87 with:
88 registry: ghcr.io
89 username: ${{ github.actor }}
90 password: ${{ secrets.GITHUB_TOKEN }}
91
92 # Actually build the image
93 - name: Build
94 uses: docker/bake-action@v6
95 id: builder
96 with:
97 # This ensures we use a local file. Otherwise, it will default to the
98 # current git repository at the current commit. If your Bake file
99 # is not generated but static, maybe you want this.
100 source: .
101 targets: ${{ matrix.target }}
102 # Here, we forcefully set the tags to the base image names.
103 # This can also be done in the Bake file generation step.
104 # Additionally, we force the build to a single architecture.
105 set: |
106 *.tags=index.docker.io/username/imagename
107 *.tags=ghcr.io/username/imagename
108 *.platform=${{ matrix.platforms }}
109
110 # By saving the output metadata to a file, it makes it easier for debugging
111 # and referencing in other later steps
112 - name: Save Build Metadata
113 run: echo '${{ steps.builder.outputs.metadata }}' > build-metadata.json
114
115 # Upload artifact for debugging purposes
116 - name: Upload Build Metadata
117 uses: actions/upload-artifact@v6
118 with:
119 path: build-metadata.json
120 # A unique name is required as this is a matrix step
121 name: build-metadata-${{ hashFiles('build-metadata.json') }}
122
123 # This is another excercise left to the reader. Somehow, this step
124 # needs to known what tags to update in the registries. This can be
125 # potentially be extracted from the Bake file or by some other means.
126 # However, it cannot come from the `builder` step, because that output will
127 # not have the tags since it was intentionally stripped to ensure
128 # `push-by-digest` works.
129 - name: Upload Digest
130 run: |
131 docker buildx imagetools create --append ${{ fromJSON(needs.bake.outputs.metadata).tag_cmd }} $(cat build-metadata.json | jq -r '.webtrees.[ "containerimage.digest" ]')
132 docker buildx imagetools create --append ${{ fromJSON(needs.bake.outputs.metadata).tag_cmd }} $(cat build-metadata.json | jq -r '.webtrees.[ "containerimage.digest" ]')
Obviously, there are other ways you can approach this depending on how you tag images, whether or not you commit a Bake file, etc., but I’m found this approach to work well for me for what I’m doing. It also manages to skip the final “merge” step all of the official examples show which adds complexity.