Docker & ContainersIV Β· ImagesManifests
Manifests, digests, and content addressing
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
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)"]
| Name | What it hashes | Where you see it | Answers |
|---|---|---|---|
| Index digest | The image index (manifest list) | RepoDigests, docker pull output | βWhat did the tag resolve to?β |
| Manifest digest | One platformβs manifest | buildx imagetools inspect, crane digest --platform | βWhat am I running on this architecture?β |
| Image ID | The image config JSON | docker 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.
$ docker image inspect nginx:1.27 --format 'id={{.Id}}{{println}}repo={{index .RepoDigests 0}}'id=sha256:d5f28ef21aabd54d6a48d8b9d3b8e5b1e0c1a2f9b7d3c4e5a6f7089abcdef0123
repo=nginx@sha256:0c1a2f9b7d3c4e5a6f7089abcdef0123d5f28ef21aabd54d6a48d8b9d3b8e5b1Illustrative output
$ docker image inspect --format '{{index .RepoDigests 0}}' myapp:devtemplate parsing error: template: :1:2: executing "" at <index .RepoDigests 0>: \
error calling index: reflect: slice index out of rangeIllustrative 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:
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
$ docker buildx imagetools inspect nginx:1.27Name: 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-manifestIllustrative 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.
$ docker pull --platform linux/arm64 myorg/myapp:1.0.0no matching manifest for linux/arm64 in the manifest list entriesIllustrative 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
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'| Tool | Needs a local pull? | Notes |
|---|---|---|
docker image inspect | Yes | Local view. .Id is the config hash, not the manifest digest |
docker manifest inspect | No | Documented as experimental β functionality may change or be removed |
docker buildx imagetools inspect | No | The supported way to read a remote manifest. --raw for the JSON, --format for a Go template |
crane | No | Single static binary, no daemon, no Docker install. Ideal in CI and on a jump host |
Pinning by digest, and what can still break it
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
Q1. You deployed `myapp@sha256:aaa`. On the host, `docker image inspect --format "{{.Id}}"` prints `sha256:ddd`. What does that tell you?
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?
Q3. Which statements about layer digests are true? Select all that apply.
Q4. An image built locally and never pushed has an empty `RepoDigests` array.
Q5. Which tool should a release pipeline use to read a remote manifest without pulling it?
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.