Arm is no longer a niche. Developer laptops, cloud instances and edge devices all run it, and an image that only exists for amd64 is one somebody has to work around.
Producing both from one repository is well supported and has one genuine trap: the easy approach is emulation, and emulation is slow enough to reshape your CI budget.
What a multi-platform image is
Section titled “What a multi-platform image is”Not one image. An index — sometimes called a manifest list — whose digest points at a set of per-platform manifests, each with its own digest and its own layers.
ghcr.io/example-org/api:v2.4.1 (index, digest sha256:aaa…)├── linux/amd64 (manifest, digest sha256:bbb…)└── linux/arm64 (manifest, digest sha256:ccc…)A client pulling the tag selects the manifest matching its own platform. From the user’s point of view there is one image; from the registry’s there are three objects.
The consequence for deployment: the index digest is what you promote. It works on every supported platform. A per-platform manifest digest pins to one architecture — occasionally deliberate, usually a mistake, and it produces a pod that will not schedule on half your nodes.
A surprise worth pre-empting: pulling the same index digest on two machines with different architectures gives different content. Both are correct. Somebody comparing “the same image” across a Mac and a CI runner and finding differences has usually hit this.
Two ways to build
Section titled “Two ways to build”The decision that determines whether this is cheap or expensive.
Emulation
Section titled “Emulation”One runner builds every platform, using QEMU to emulate the foreign architecture.
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v4 - uses: docker/build-push-action@v7 with: platforms: linux/amd64,linux/arm64 push: trueSimple — three lines, one job, no infrastructure.
Slow. Docker’s own documentation is direct about this: emulation with QEMU can be much slower than native builds, especially for compute-heavy tasks. In practice a compilation step under emulation can take several times as long as native. For an interpreted application copying files it is tolerable; for anything that compiles it is not.
Occasionally broken. Some toolchains behave badly under emulation — JIT compilers, anything using architecture-specific instructions, some garbage collectors, some test suites. When a build works natively and fails or hangs under QEMU, this is usually why, and the error message rarely says anything about emulation. Recognising the pattern saves hours of adjusting a Dockerfile that is not the problem.
Native runners
Section titled “Native runners”One job per platform on a matching runner, then a step that combines the results into an index.
jobs: build: strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: ubuntu-latest - platform: linux/arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7 - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/build-push-action@v7 id: build with: platforms: ${{ matrix.platform }} outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=trueFast — each platform builds natively, in parallel. Wall-clock is the slowest single platform rather than the sum.
More moving parts — a matrix, digest passing between jobs, and a merge step.
Needs arm64 runners. GitHub-hosted arm runners exist; availability and naming depend on your plan and change over time, so check what your account actually offers rather than copying a label from an example.
push-by-digest=true pushes the platform manifest without a tag. The tag is applied later by the merge job, which is what prevents a half-finished index from being tagged.
Cross-compilation
Section titled “Cross-compilation”A third option worth knowing: build for the target architecture on the host’s architecture, using the language toolchain rather than emulation.
FROM --platform=$BUILDPLATFORM golang:1.24 AS buildARG TARGETOS TARGETARCHWORKDIR /srcCOPY . .RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ./cmd/app
FROM gcr.io/distroless/staticCOPY --from=build /out/app /appENTRYPOINT ["/app"]$BUILDPLATFORM is the builder’s platform; $TARGETOS and $TARGETARCH are the target’s, supplied by BuildKit. The compile runs natively and only the target binary is foreign.
Fastest option where the language supports it — Go and Rust do this well, and Docker’s documentation recommends it over emulation where possible. Not available for everything: anything with native extensions or a runtime that must be built for the target usually cannot.
Choosing
Section titled “Choosing”| Emulation | Native runners | Cross-compilation | |
|---|---|---|---|
| Setup effort | Minimal | Moderate | Depends on language |
| Build speed | Slow | Fast | Fastest |
| Infrastructure | None | arm64 runners | None |
| Works for | Anything | Anything | Compiled languages |
| Toolchain issues | Sometimes | No | Language-specific |
Start with emulation to prove the image works on both platforms. Move to native or cross-compilation when build time becomes a problem — which for anything that compiles is quickly.
The merge step
Section titled “The merge step”With native runners, each job pushes an untagged platform manifest. A final job assembles them.
merge: needs: build runs-on: ubuntu-latest steps: - uses: docker/setup-buildx-action@v4 - uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - uses: docker/metadata-action@v6 id: meta with: images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern=v{{version}} type=sha,prefix=sha-,format=long - name: Create the index run: | docker buildx imagetools create \ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf 'ghcr.io/${{ github.repository }}@%s ' ${{ needs.build.outputs.digests }})docker buildx imagetools create builds an index from existing manifests without rebuilding anything.
Tags are applied here, once, when every platform has succeeded. That is the property worth having: a tag never points at a partially built index.
Digests travel between jobs through job outputs or artifacts. The mechanics vary; the principle is that the build jobs produce digests and the merge job consumes them.
Which platforms
Section titled “Which platforms”More is not better. Each one costs build time, storage and a surface you have implicitly promised to support.
linux/amd64 — the default assumption almost everywhere.
linux/arm64 — developer laptops, arm cloud instances, and increasingly the cheaper option in a cloud bill.
linux/arm/v7 — 32-bit arm, for older single-board devices. Only if you know you have such users.
Anything else — only on evidence.
Two is the right answer for most projects. Adding a third platform because it is one more entry in a list is how a release goes from four minutes to eleven for a user base of nobody — and once published, a platform is something people will report bugs against, so dropping it later is a breaking change rather than a tidy-up.
Check your dependencies support the platform before promising it. A base image without an arm64 variant, or a native module with no arm64 build, will fail the build — usually at the least convenient moment, and usually after somebody has already announced support.
The builder and the image store
Section titled “The builder and the image store”A detail that explains a common confusion about where multi-platform images go.
The containerd image store is the default in Docker Engine 29.0 and later, and it supports multi-platform images natively — a multi-platform build can be loaded into the local store and inspected without pushing.
The docker-container driver supports multi-platform builds too, and the result is not loaded into the engine’s image store. It must be pushed directly with --push, or exported. This is the source of the “the build succeeded but docker images shows nothing” question.
What this means in CI: push directly. docker/build-push-action with push: true does the right thing, and trying to load a multi-platform build locally in order to test it before pushing runs into exactly the limitation above.
What it means locally: check which store you have. On an older engine, a local multi-platform build needs --push to a registry or an explicit export, and the workaround of building each platform separately and loading them individually is what people end up doing.
Builder instances persist. docker buildx create makes a builder that survives between builds and holds its own cache. In CI the setup action creates one per run; locally a stale builder holding a large cache is a common cause of unexpected disk usage, and docker buildx prune is the cleanup.
Build caching across platforms
Section titled “Build caching across platforms”Caching gets more valuable and more complicated with several platforms.
The cache is per platform. An amd64 build and an arm64 build produce different layers and cache separately. A cache hit for one says nothing about the other.
With a matrix, each job caches independently. That is correct and it means the cache key should include the platform, or the jobs will evict each other’s entries.
cache-to: type=gha,mode=max caches intermediate layers as well as the final ones, which matters more for multi-stage builds. mode=min caches only the final stage and is smaller.
Registry-backed caching — type=registry pointing at a dedicated cache tag — persists longer than the Actions cache and is shared across branches. Worth it for slow builds; it costs registry storage and adds a tag you must exclude from retention policies.
The first build of a new platform is always slow. Adding arm64 to an existing pipeline produces one expensive run and then normal ones. Teams sometimes conclude arm builds are slow when they have measured exactly one cold cache.
Watch the cache size. Multi-platform caches grow faster, and an Actions cache that exceeds the repository’s quota starts evicting the entries you wanted.
Testing both platforms
Section titled “Testing both platforms”Building for a platform is not evidence it works there.
Native runners let you test natively. Run the test suite in the job that built that platform.
Under emulation you can run tests emulated, slowly, and it does catch architecture-specific failures.
At minimum, start the container on each platform and check it responds. A surprising number of arm64 problems are “the binary is for the wrong architecture” or “a native module failed to load”, both of which surface immediately on startup.
The failures that are genuinely architecture-specific, and worth knowing to look for: unaligned memory access, differences in default signedness of char, endianness assumptions (rare on both amd64 and arm64, which are both little-endian), timing and concurrency behaviour under different memory models, and native dependencies without a build for the target.
Attestations per platform
Section titled “Attestations per platform”Provenance and SBOM attestations are generated per platform and referenced from the index.
with: platforms: linux/amd64,linux/arm64 provenance: true sbom: trueOne index, several attestations. Each platform’s build has its own provenance and its own SBOM, because they genuinely contain different packages.
Verification tooling handles this, and it is worth knowing when the output shows more entries than expected.
With native runners the attestations come from different jobs, which is correct — they record different builds on different machines, and that is what happened.
Adding a platform to an existing project
Section titled “Adding a platform to an existing project”The migration, in the order that surfaces problems cheaply.
-
Check every base image has the target platform.
docker buildx imagetools inspect node:22-slimlists what it supports. A base without arm64 stops the project here, and finding that out first saves a day. -
Check native dependencies. Anything compiled during install — native Node modules, Python wheels, Go with cgo — needs a build for the target. Package registries often have prebuilt wheels for amd64 only, and the fallback is compiling from source in the image, which under emulation is slow enough to time out.
-
Build the new platform once, manually, before touching CI. Locally with emulation, or on a borrowed arm machine. Confirm it builds and starts.
-
Add it to the pull request build only. Not to releases yet. Let it run for a week and see whether it is flaky or merely slow.
-
Measure the cost. Build minutes, and for hosted runners the actual billing difference. This is the number that decides between emulation and native runners.
-
Move to native runners or cross-compilation if the cost is unacceptable — which for a compiled language it usually is.
-
Add it to the release workflow last, once the pull request builds have been stable.
-
Announce it. A multi-platform image that consumers do not know about does nobody any good, and the release note is where they find out.
Steps 1 and 2 account for most abandoned attempts. They take twenty minutes and they are the ones people skip because adding a platform looks like a one-line change to the platforms: list.
Runtime considerations
Section titled “Runtime considerations”Building both platforms is half the job; running them is the other half.
Kubernetes schedules on node architecture using the kubernetes.io/arch label. A multi-platform image works on a mixed cluster without any manifest change, which is the whole benefit — the kubelet pulls the manifest matching its node.
A single-platform image on a mixed cluster fails at pull time, on the nodes that do not match, with an error about no matching manifest. The pod schedules and then cannot start, which looks like an application problem.
Node selectors and affinity are the tool where you genuinely need one architecture — a workload with an amd64-only dependency, for example. Use them explicitly rather than relying on the scheduler happening to place it correctly.
Mixed clusters are increasingly normal because arm instances are frequently cheaper. That makes multi-platform images an operational enabler rather than a nice-to-have: without them, a cost optimisation in the node pool becomes a blocked migration.
Performance can differ per platform. The same image on comparable amd64 and arm64 instances does not necessarily perform identically, and for latency-sensitive workloads that is worth measuring rather than assuming.
Cost, in build minutes
Section titled “Cost, in build minutes”The number that decides the approach, and one worth actually measuring rather than estimating.
Emulation costs one runner for the duration of the slowest platform. The platforms build sequentially within one job, so the total is roughly the sum — and the emulated one dominates.
Native runners cost two runners in parallel for roughly the duration of the slower one, plus a short merge job. Wall-clock is much better; billed minutes may be similar or higher depending on your runner pricing, because you are paying for two machines rather than one.
Cross-compilation costs one runner, natively, and is usually the cheapest on both measures where it is available.
The measure that matters depends on your constraint. If developers are waiting on pull request builds, wall-clock is what you are optimising and native runners win clearly. If you are optimising a bill, do the arithmetic — arm runners are not necessarily priced the same as amd64 ones.
A cheap middle path: build only the native platform on pull requests, and both platforms on releases. Developers get fast feedback, releases get full coverage, and the arm-specific problems surface at release time rather than never. The risk is finding an arm build failure during a release, which argues for a scheduled multi-platform build on the default branch as well.
Common mistakes
Section titled “Common mistakes”Emulating a compiled build. Several times slower, and sometimes broken in ways the error does not explain.
Deploying a per-platform digest. Pins to one architecture; pods will not schedule elsewhere.
Tagging before every platform succeeded. A tag pointing at a partial index.
fail-fast: true on the matrix. One failure hides the other.
Adding platforms speculatively. Build time and storage for users who do not exist.
Not testing the non-native platform. Building is not evidence of working.
Assuming dependencies are multi-platform. Base images and native modules frequently are not.
Comparing the same digest across architectures and expecting the same content. That is the index working as designed.
Debugging a platform-specific failure
Section titled “Debugging a platform-specific failure”The failures are distinctive once you know the shape.
“exec format error”. The binary is for the wrong architecture. Almost always a cross-compilation step that did not honour $TARGETARCH, or a downloaded binary whose URL is hard-coded to one platform. That second case is common: a RUN curl fetching a release asset with amd64 in the filename works fine until you build for arm.
A native module failing to load at startup. The package was installed for the build platform rather than the target. Under emulation this usually works and is slow; with cross-compilation it fails, because the compile ran natively and the module did not.
The build hangs under emulation. QEMU and some toolchains interact badly — certain garbage collectors and JITs are known offenders. Building that platform natively resolves it, and no amount of adjusting the Dockerfile will.
Tests passing on one platform and failing on the other. Genuine architecture differences, and worth investigating rather than skipping. The usual causes are concurrency assumptions that hold under one memory model and not the other, and timing-sensitive tests that were always fragile and only now fail.
“no matching manifest for linux/arm64”. The image is single-platform. Either the build did not produce that platform, or something is pulling a per-platform digest rather than the index.
The diagnostic that separates most of these: build and run that single platform natively, without emulation and without the matrix. If it works natively and fails under QEMU, the problem is emulation. If it fails both ways, it is the Dockerfile.
Mental model
Section titled “Mental model”A multi-platform image is an index of images. The tag points at the index; the index points at one manifest per platform; each manifest is a real image with its own digest.
Every practical rule follows: promote the index digest, tag only after all platforms succeed, expect per-platform attestations, and remember that pulling the same reference on different machines is meant to give different content.
What you learned
Section titled “What you learned”- A multi-platform image is an index whose digest resolves to per-platform manifests
- Promote the index digest; a per-platform digest pins to one architecture
- Emulation is simple and much slower, particularly for compute-heavy builds
- Native runners build in parallel; cross-compilation is fastest where the language allows
push-by-digest=trueplus a merge step means tags are applied only after every platform succeedsfail-fast: falseso one platform’s failure does not hide another’s- Two platforms is the right answer for most projects
- Attestations are generated per platform and referenced from the index
Exercise
Section titled “Exercise”Use a disposable repository. No production credentials.
-
Build a small image for
linux/amd64,linux/arm64using QEMU emulation. Note the total time. -
Inspect the result with
docker buildx imagetools inspect. Predict: how many digests do you see? -
Pull by the index digest and inspect the running image’s architecture. Predict: which one did you get?
-
Add a compilation step — a small Go or Rust program. Rebuild under emulation and compare the time.
-
Convert it to cross-compilation using
$BUILDPLATFORMand$TARGETARCH. Compare again. -
Split into a matrix with
push-by-digestand a merge job. Make one platform fail deliberately withfail-fast: false. Predict: does a tag get applied? -
Add
provenance: trueand inspect what attaches to the index. -
Delete the repository and the packages.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.