Skip to main content
RunBook Academy

Docker & ContainersIV Β· ImagesManifests

Manifests, digests, and content addressing

Intermediate⏱ ~30 mindocker

What you'll learn

  • Distinguish the image ID, the manifest digest and the index digest, and say what each one covers
  • Read an OCI image index and explain how a pull selects a platform entry
  • Choose between `docker image inspect`, `docker manifest inspect` and `crane` for a given question
  • Pin a deployment by digest, and know what can still break that pin

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

A tag like nginx:1.27 is a friendly alias that the publisher can move whenever they like. The stable identity of an image is a SHA-256 digest.

That much is well known. What is not well known, and what causes almost all the confusion in practice, is that there are three different digests associated with any given image, they have different values, and every tool prints a different one by default. People compare the wrong pair, conclude the registry is lying to them, and lose an afternoon.

Three hashes, three questions

flowchart TB
  T["tag: nginx:1.27"] -->|registry resolves| IDX["image index<br/>sha256:aaa β€” the index digest"]
  IDX --> M1["manifest linux/amd64<br/>sha256:bbb β€” the manifest digest"]
  IDX --> M2["manifest linux/arm64<br/>sha256:ccc"]
  M1 --> CFG["config JSON<br/>sha256:ddd β€” the image ID"]
  M1 --> L1["layer blob sha256:eee (compressed)"]
  M1 --> L2["layer blob sha256:fff (compressed)"]
NameWhat it hashesWhere you see itAnswers
Index digestThe image index (manifest list)RepoDigests, docker pull outputβ€œWhat did the tag resolve to?”
Manifest digestOne platform’s manifestbuildx imagetools inspect, crane digest --platformβ€œWhat am I running on this architecture?”
Image IDThe image config JSONdocker images, .Id in docker image inspectβ€œWhat is on this host’s disk?”

The OCI configuration specification is explicit about the third one: β€œEach image’s ID is given by the SHA256 hash of its configuration JSON.” The image ID is not the manifest digest and never equals it.

Read-only / Safeboth identities
$ docker image inspect nginx:1.27 --format 'id={{.Id}}{{println}}repo={{index .RepoDigests 0}}'
id=sha256:d5f28ef21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c4e5a6f7089abcdef0123
repo=nginx@sha256:0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1

Illustrative output

Read-only / Safenever pushed
$ docker image inspect --format '{{index .RepoDigests 0}}' myapp:dev
template parsing error: template: :1:2: executing "" at <index .RepoDigests 0>: \
error calling index: reflect: slice index out of range

Illustrative output

This is worth building into scripts, because a CI job that resolves a digest before pushing will fail here rather than at the point of the real mistake. Test the length first:

Read-only / Safesafe resolve
IMAGE=myapp:dev
DIGEST=$(docker image inspect --format '{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}' "$IMAGE")

if [ -z "$DIGEST" ]; then
  echo "$IMAGE has no registry digest: it has never been pushed or pulled" >&2
  exit 1
fi
echo "$DIGEST"

What a digest actually covers

Reading the index

Read-only / Safeimagetools
$ docker buildx imagetools inspect nginx:1.27
Name:      docker.io/library/nginx:1.27
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1

Manifests:
Name:      docker.io/library/nginx:1.27@sha256:4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c
MediaType: application/vnd.oci.image.manifest.v1+json
Platform:  linux/amd64

Name:      docker.io/library/nginx:1.27@sha256:89abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c4e5a6f70
MediaType: application/vnd.oci.image.manifest.v1+json
Platform:  linux/arm64

Name:      docker.io/library/nginx:1.27@sha256:f21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28
MediaType: application/vnd.oci.image.manifest.v1+json
Platform:  unknown/unknown
Annotations:
  vnd.docker.reference.type:   attestation-manifest

Illustrative output

Two things in that output are worth naming.

Digest: at the top is the index digest β€” the value a tag resolves to and the value you should pin. The per-platform digests below it are manifest digests.

unknown/unknown entries are not corruption. They are attestation manifests: SBOMs and build provenance attached to the image, carrying a vnd.docker.reference.type annotation. BuildKit adds them by default for multi-platform builds. A tool that filters platforms naively will show them as a mysterious extra architecture, and a script that counts index entries to check β€œdid all three architectures build” will get the wrong number.

The OCI index specification requires schemaVersion (which must be 2) and manifests, and when mediaType is present it must be application/vnd.oci.image.index.v1+json. Each entry may carry a platform object with required architecture and os, and optional os.version, os.features and variant β€” variant being how linux/arm/v7 is distinguished from linux/arm/v6.

How a pull picks an entry

This is client-side, and getting that backwards leads to blaming the registry.

The registry serves whatever the Accept header asks for. When the reference resolves to an index, the registry returns the whole index. The client then walks the manifests array, compares each platform object against its own OS, architecture and variant, picks a match, and issues a second request for that manifest’s digest.

Read-only / Safeno match
$ docker pull --platform linux/arm64 myorg/myapp:1.0.0
no matching manifest for linux/arm64 in the manifest list entries

Illustrative output

There is no 404 here and nothing wrong on the registry side β€” it served the index it has. The image simply does not have that platform, and the diagnosis is docker buildx imagetools inspect to see what platforms it does have. Chasing this as a registry, auth, or network problem is the standard wrong turn, and imagetools inspect ends it in one command.

The same mechanism explains the more confusing variant: a pull that succeeds and then fails at runtime with exec format error. That happens when the index does contain an entry your client matched β€” often because somebody forced --platform linux/amd64 on an arm64 host, or because the image is a single-platform amd64 image with no index at all, in which case there is nothing to select and the client takes what it is given.

Three tools, three jobs

Read-only / Safewhich tool
IMAGE=nginx:1.27

# Local: what is on this host's disk? Requires the image to be pulled.
docker image inspect "$IMAGE" --format '{{.Id}} {{.Architecture}} {{.Os}}'

# Remote: what does the registry serve for this reference?
# Does not pull layers. Not experimental.
docker buildx imagetools inspect "$IMAGE"

# Remote, scriptable: just the digest, or just the config.
crane digest "$IMAGE"
crane config "$IMAGE" | jq '.config.Entrypoint'
crane manifest "$IMAGE" | jq '.manifests[].platform'
ToolNeeds a local pull?Notes
docker image inspectYesLocal view. .Id is the config hash, not the manifest digest
docker manifest inspectNoDocumented as experimental β€” functionality may change or be removed
docker buildx imagetools inspectNoThe supported way to read a remote manifest. --raw for the JSON, --format for a Go template
craneNoSingle static binary, no daemon, no Docker install. Ideal in CI and on a jump host

Pinning by digest, and what can still break it

Read-only / Safepin
IMAGE=nginx:1.27
DIGEST=$(crane digest "$IMAGE")
echo "nginx@$DIGEST"
services:
  web:
    # The index digest: correct on every architecture in the index.
    image: nginx@sha256:0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1

Two failure modes survive digest pinning, and both are worth knowing before you rely on it.

Pinning the wrong digest of the two. If you pin a manifest digest rather than the index digest, you have pinned one architecture. It works on your amd64 CI runner and fails on the arm64 node with no matching manifest, months later, during a capacity event when somebody adds a Graviton host. Pin the value that imagetools inspect prints as Digest: at the top, or that crane digest returns for the tag.

Registry garbage collection can delete a pinned digest. A digest that no tag points to is untagged content. The distribution registry’s garbage collector is run manually as registry garbage-collect, and with --delete-untagged it removes exactly this. Hosted registries run their own retention policies with similar semantics. So a digest pin is only as durable as the registry’s retention: pin the digest and keep a tag on it, or mirror the image into a registry you control.

Knowledge check

Knowledge check Β· 6 questions

  1. Q1. You deployed `myapp@sha256:aaa`. On the host, `docker image inspect --format "{{.Id}}"` prints `sha256:ddd`. What does that tell you?

  2. Q2. `docker pull --platform linux/arm64 myorg/myapp:1.0.0` returns "no matching manifest for linux/arm64 in the manifest list entries". Where is the fault?

  3. Q3. Which statements about layer digests are true? Select all that apply.

  4. Q4. An image built locally and never pushed has an empty `RepoDigests` array.

  5. Q5. Which tool should a release pipeline use to read a remote manifest without pulling it?

  6. Q6. A deployment pinned to a digest starts failing with a manifest-not-found error, months after it was working. What is the most likely cause?

Passing score: 75%. Answers are checked in this browser.