Multi Arch Image
A multi arch image is one image reference that resolves through a manifest list to a per architecture image, so a single tag serves x64 and ARM64 clients.
A multi arch image is an image reference that resolves to several per architecture images through a manifest list, so one tag such as ghcr.io/example/app:1.4.0 serves both x64 and ARM64 clients. A client asks the registry for that tag, reads the list it gets back, and pulls only the entry whose platform matches its own operating system and CPU architecture.
The tag therefore points at an index rather than at a single image. Most questions about multi arch images follow from that one level of indirection: what the index holds, how a client picks an entry, and how the index gets published after the per architecture builds finish.
Definition
The index goes by two names because two specifications describe the same structure. The OCI form is the image index defined in the OCI image index specification, and the older Docker form is the manifest list described in the docker manifest reference, both checked on 2026-08-13. Registries store either one, and current clients read either one.
| Format | Media type of the index | Media type of each entry |
|---|---|---|
| OCI image index | application/vnd.oci.image.index.v1+json | application/vnd.oci.image.manifest.v1+json |
| Docker manifest list | application/vnd.docker.distribution.manifest.list.v2+json | application/vnd.docker.distribution.manifest.v2+json |
An index is a small JSON document. It holds no layers of its own, only pointers to the manifests that do:
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:9f1c3b0a4d5e6f708192a3b4c5d6e7f80912a3b4c5d6e7f8091a2b3c4d5e6f708",
"size": 1203,
"platform": { "architecture": "amd64", "os": "linux" }
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:4b7a1c2d3e4f50617283940a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e",
"size": 1203,
"platform": { "architecture": "arm64", "os": "linux" }
}
]
}Each entry carries the fields a client needs in order to route itself.
| Field | What it does |
|---|---|
digest | The content address of one per architecture manifest, which is what the client fetches next |
size | Byte length of that manifest, so the client can bound the request before making it |
platform.os | linux, windows, or darwin |
platform.architecture | amd64, arm64, 386, ppc64le, s390x, and the other values the specification lists |
platform.variant | ARM revision selectors such as v6, v7, and v8 |
Resolution runs in two registry calls. The client issues GET /v2/<name>/manifests/<tag> with an Accept header naming the media types it understands. The registry returns the index when the client accepts index media types, and returns a single manifest when it does not, which is how old clients still work against a multi arch tag. The client then scans the manifests array for a platform block matching itself and issues a second GET /v2/<name>/manifests/<digest> for that entry. Layers come after that, again by digest.
One consequence is worth stating plainly. Pinning a deployment to ghcr.io/example/app@sha256:... using the index digest keeps the multi arch behavior, because the pinned document is still the index. Pinning to the digest of one entry pins the deployment to that single architecture.
BuildKit also stores provenance and SBOM attestations inside the same index, documented in the attestation storage reference. Those manifests carry "platform": {"architecture": "unknown", "os": "unknown"} so that no client selects them by accident, and they are the reason an index built with default settings often shows four entries where a reader expected two.
Example
The following GitHub Actions workflow builds each architecture on its own machine, pushes each result to the registry by digest with no tag attached, then joins the two digests into one manifest list in a second job.
name: image
on:
push:
branches: [main]
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
matrix:
include:
- platform: linux/amd64
arch: amd64
- platform: linux/arm64
arch: arm64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
run: |
docker buildx build \
--platform ${{ matrix.platform }} \
--provenance=false \
--output type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true \
--metadata-file metadata.json \
.
jq -r '."containerimage.digest"' metadata.json > digest-${{ matrix.arch }}.txt
- uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.arch }}
path: digest-${{ matrix.arch }}.txt
manifest:
needs: build
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@v4
with:
pattern: digest-*
merge-multiple: true
path: digests
- name: Create the manifest list
run: |
docker buildx imagetools create \
--tag ${{ env.IMAGE }}:${{ github.sha }} \
--tag ${{ env.IMAGE }}:latest \
$(printf "${{ env.IMAGE }}@%s " $(cat digests/*.txt))Two details in the build job earn their place. push-by-digest=true uploads the image without claiming a tag, which keeps a half finished pair of architectures from ever appearing under :latest. --provenance=false keeps each pushed digest a plain image manifest, so imagetools create receives two manifests instead of two small indexes.
The arm64 leg needs a machine that can emit arm64 output. That is either an ARM64 runner executing the build natively or an x64 runner with QEMU emulation installed by docker/setup-qemu-action.
After the second job finishes, reading the tag shows the list that was published:
$ docker buildx imagetools inspect ghcr.io/example/app:latest
Name: ghcr.io/example/app:latest
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:1d0f7c9b2a3e4d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7
Manifests:
Name: ghcr.io/example/app:latest@sha256:9f1c3b0a4d5e6f70...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: ghcr.io/example/app:latest@sha256:4b7a1c2d3e4f5061...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64docker pull ghcr.io/example/app:latest on an x64 host now resolves to sha256:9f1c3b0a, and the same command on an ARM64 host resolves to sha256:4b7a1c2d. Neither host learns about the other entry, and the pull command is identical on both. Adding --platform linux/amd64 forces the first entry on either host, which is the usual way to reproduce an x64 failure from an ARM64 laptop.
A single invocation produces the same index when one machine can build both architectures: docker buildx build --platform linux/amd64,linux/arm64 --push . writes the index directly. The split shape above exists so that each architecture builds on hardware that matches it, and so that a failure in one architecture leaves the published tag untouched.
Related Terms
- How to push a manifest list from GitHub Actions: the direct answer for a workflow that already builds both architectures and needs the join step.
- Multi platform Docker builds in GitHub Actions: the full workflow shape, including emulation, native ARM64 jobs, and cache handling across the matrix.
- Image digest, the content address an index entry points at: how a
sha256:reference is computed and why pinning the index digest differs from pinning one entry. - OCI image index specification: the upstream field list for the index document.
- WarpBuild Docker builder documentation: builder profiles, the architectures a profile can be configured with, and the actions that attach a build to one.
- WarpBuild cloud runner documentation: the runner labels available for x64 and ARM64 jobs.
- WarpBuild pricing: per minute rates by runner type and by builder size.
FAQ
What is the difference between a manifest list and an image index?
They are the same idea in two formats. A Docker manifest list carries the media type application/vnd.docker.distribution.manifest.list.v2+json and an OCI image index carries application/vnd.oci.image.index.v1+json. Both hold a list of per architecture manifests with a platform block on each entry, and registries and clients accept either.
How does a client know which architecture to pull?
The client requests the tag with an Accept header listing the manifest media types it understands. When the registry returns an index, the client compares its own operating system and CPU architecture against the platform block of each entry and fetches the matching manifest by digest. Passing --platform to docker pull or docker run overrides that choice.
Why does my manifest list contain unknown/unknown entries?
Those are attestation manifests. BuildKit stores provenance and SBOM attestations as extra manifests inside the index with the platform set to unknown/unknown, so tools that read the index without filtering will show them next to the real architecture entries. Building with --provenance=false leaves the index with the architecture entries alone.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.