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.

FormatMedia type of the indexMedia type of each entry
OCI image indexapplication/vnd.oci.image.index.v1+jsonapplication/vnd.oci.image.manifest.v1+json
Docker manifest listapplication/vnd.docker.distribution.manifest.list.v2+jsonapplication/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.

FieldWhat it does
digestThe content address of one per architecture manifest, which is what the client fetches next
sizeByte length of that manifest, so the client can bound the request before making it
platform.oslinux, windows, or darwin
platform.architectureamd64, arm64, 386, ppc64le, s390x, and the other values the specification lists
platform.variantARM 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/arm64

docker 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.

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.