Inspect an OCI Image's Platform Before You Pull It
Last edited on August 12, 2026

A container tag can look like one deployable object while actually naming an OCI image index: a small content-addressed document whose descriptors point to separate manifests for AMD64, ARM64, 32-bit ARM, and other platforms. Pulling first and diagnosing later wastes transfer time and turns a release-admission question into a runtime incident. The registry can answer the important question before any filesystem layer is downloaded: does this exact tag contain exactly one runnable manifest for the target OS, architecture, and variant?

This guide builds a read-only receipt against the public library/alpine:3.22 tag. It verifies the index’s exact bytes, filters non-runnable descriptors, selects linux/amd64, verifies the child manifest and config blob, proves that an unsupported platform is absent, and records both immutable digests. The commands use Docker Hub’s anonymous pull scope, but the evidence model applies to any OCI-compatible registry whose authentication flow you are authorized to use.

A tag can name a platform decision, not one runtime image

OCI’s Image Index specification defines an index as a list of descriptors. Each runnable image descriptor can declare platform.os, platform.architecture, and an optional platform.variant. A runtime resolves the tag, reads that index, and chooses a child manifest compatible with the requested or host platform. Docker’s multi-platform documentation describes the same automatic selection from a manifest list.

That creates two immutable identities:

  • The index digest identifies the complete platform menu.
  • A child manifest digest identifies one platform-specific manifest and its config and layer descriptors.

They are not interchangeable. Pinning the index preserves platform selection, which is useful for a heterogeneous deployment. Pinning the linux/amd64 child locks that reference to AMD64, even when another child in the same index supports ARM64. A mutable tag is neither of those receipts; its owner can move it later.

An OCI tag resolves to an index and then to one platform-specific child manifestA tag points to one immutable index digest. The index branches to Linux AMD64, ARM64 and other platform child digests. Linux AMD64 is highlighted. Pinning the index retains platform selection; pinning the child locks one platform.tag3.22index digestsha256:1435…linux / amd64sha256:7c8c…linux / arm64 / v8sha256:2c9d…other platform childrenone digest eachIndex pin keeps platform choiceChild pin locks one platform
A tag can resolve through one immutable index to multiple immutable child manifests; the two digest levels preserve different deployment choices.

That diagram explains the object graph but does not prove what a live registry returned. The following HTTP responses and byte hashes do that. If the target system is itself a private registry, Harbor private registry operations covers retention, scanning, replication, and recovery around the artifact store; this article owns only pre-pull platform admission.

Establish a read-only, marker-owned inspection lab

Run the lab on an authorized workstation with Bash, curl, jq, sha256sum, awk, and sed. It creates one mode-0700 directory under /tmp and refuses to reuse an existing path. The bearer token has public pull scope for one repository. iximiuz’s no-pull image-inspection lab provides useful practice with the Registry API object flow. Do not print the token, add shell tracing, or reuse this anonymous flow for a registry that requires credentials.

set -Eeuo pipefail
umask 077

LAB=/tmp/voxfor-oci-platform-143-lab
MARKER=$LAB/.voxfor-owned-lab
REPO=library/alpine
TAG=3.22
REGISTRY=https://registry-1.docker.io
ACCEPT='application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json'

[[ ! -e $LAB ]]
install -d -m 0700 "$LAB"
printf '%s\n' 'voxfor-oci-platform-143' > "$MARKER"
for tool in curl jq sha256sum awk sed; do command -v "$tool" >/dev/null; done
TOKEN=$(curl -fsS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:${REPO}:pull" | jq -er .token)
[[ ${#TOKEN} -gt 100 ]]
printf 'HOST_ARCH=%s CURL=%s JQ=%s\n' "$(uname -m)" "$(curl --version | sed -n '1s/ .*//p')" "$(jq --version)"

Local host architecture is context, not the requested result. Release systems should receive TARGET_OS, TARGET_ARCH, and TARGET_VARIANT from the deployment contract instead of silently copying uname -m. OCI uses values such as amd64 and arm64, while Linux often reports x86_64 and aarch64.

Verify the index media type, header digest, and exact bytes

Content negotiation matters. A registry can serve an OCI index, Docker manifest list, OCI manifest, or Docker schema-2 manifest depending on the object and Accept header. Save both headers and body, then compare Docker-Content-Digest with SHA-256 over the exact bytes received.

curl -fsS -D "$LAB/index.headers" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: $ACCEPT" \
  "$REGISTRY/v2/$REPO/manifests/$TAG" -o "$LAB/index.json"
INDEX_DIGEST=$(awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {gsub("\r", ""); print $2}' "$LAB/index.headers")
INDEX_HASH=sha256:$(sha256sum "$LAB/index.json" | awk '{print $1}')

[[ $(jq -r .schemaVersion "$LAB/index.json") == 2 ]]
[[ $(jq -r .mediaType "$LAB/index.json") == application/vnd.oci.image.index.v1+json ]]
[[ $INDEX_HASH == "$INDEX_DIGEST" ]]
printf 'INDEX=%s TOTAL_DESCRIPTORS=%s\n' \
  "$INDEX_DIGEST" "$(jq '.manifests | length' "$LAB/index.json")"

This is stronger than copying a digest displayed by a tool: the registry header, selected media representation, and raw body agree. If the media type is a single image manifest instead of an index, the tag may still be deployable, but there is no multi-platform descriptor list to search. Route that case through a separate single-manifest policy rather than letting the jq selector fail ambiguously.

For interactive discovery, the docker manifest reference offers a convenient view. Raw Registry API capture is preferable for this gate because it preserves the negotiated representation and response header beside the exact bytes used for hashing.

Filter artifact descriptors before selecting a platform

In the observed Alpine index, only eight of 16 descriptors had runnable platform identities. The other eight reported unknown/unknown and accompanied platform manifests as attestations or related artifacts. That is an observed property of this response, not a rule that every unknown descriptor is harmless. The safe policy is to select an exact known OS, architecture, and variant—not to treat every descriptor as a bootable image.

printf '%-10s %-13s %-8s %s\n' OS ARCH VARIANT DIGEST
jq -r '.manifests[] | select(.platform.os != "unknown") |
  [.platform.os, .platform.architecture, (.platform.variant // "-"), .digest] | @tsv' \
  "$LAB/index.json" |
  while IFS=$'\t' read -r os arch variant digest; do
    printf '%-10s %-13s %-8s %s\n' "$os" "$arch" "$variant" "$digest"
  done

RUNNABLE_COUNT=$(jq '[.manifests[] | select(.platform.os != "unknown")] | length' "$LAB/index.json")
ATTESTATION_COUNT=$(jq '[.manifests[] | select(.platform.os == "unknown" and .platform.architecture == "unknown")] | length' "$LAB/index.json")
TARGET_OS=linux TARGET_ARCH=amd64 TARGET_VARIANT=''
MATCH_COUNT=$(jq --arg os "$TARGET_OS" --arg arch "$TARGET_ARCH" --arg variant "$TARGET_VARIANT" \
  '[.manifests[] | select(.platform.os == $os and .platform.architecture == $arch and (.platform.variant // "") == $variant)] | length' "$LAB/index.json")
[[ $MATCH_COUNT == 1 ]]
CHILD_DIGEST=$(jq -er --arg os "$TARGET_OS" --arg arch "$TARGET_ARCH" --arg variant "$TARGET_VARIANT" \
  '.manifests[] | select(.platform.os == $os and .platform.architecture == $arch and (.platform.variant // "") == $variant) | .digest' "$LAB/index.json")
UNSUPPORTED_COUNT=$(jq '[.manifests[] | select(.platform.os == "linux" and .platform.architecture == "mips64le")] | length' "$LAB/index.json")
[[ $UNSUPPORTED_COUNT == 0 ]]
printf 'RUNNABLE=%s ATTESTATIONS=%s TARGET=%s/%s CHILD=%s UNSUPPORTED_MIPS64LE=%s\n' \
  "$RUNNABLE_COUNT" "$ATTESTATION_COUNT" "$TARGET_OS" "$TARGET_ARCH" "$CHILD_DIGEST" "$UNSUPPORTED_COUNT"

Requiring MATCH_COUNT == 1 is deliberate. Zero means the release cannot target that platform. More than one means the gate has not found a unique identity and should preserve the response for investigation. Architecture alone is not enough: ARM descriptors commonly use variants such as v6, v7, or v8, and Windows images can require additional OS-version compatibility.

This boundary is related to, but narrower than, the runtime boundary in containers versus virtual machines. A container shares the node kernel, so platform compatibility is an admission prerequisite; it is not proof of workload isolation, recovery behavior, or application health.

Hash the selected child manifest instead of trusting the pointer

Each selected index descriptor supplies a child digest. Fetch that object by digest and require agreement between three identities: the selected descriptor, the child’s response header, and SHA-256 over the child response bytes.

curl -fsS -D "$LAB/child.headers" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: $ACCEPT" \
  "$REGISTRY/v2/$REPO/manifests/$CHILD_DIGEST" -o "$LAB/child.json"
CHILD_HEADER_DIGEST=$(awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {gsub("\r", ""); print $2}' "$LAB/child.headers")
CHILD_HASH=sha256:$(sha256sum "$LAB/child.json" | awk '{print $1}')

[[ $CHILD_HEADER_DIGEST == "$CHILD_DIGEST" ]]
[[ $CHILD_HASH == "$CHILD_DIGEST" ]]
[[ $(jq -r .mediaType "$LAB/child.json") == application/vnd.oci.image.manifest.v1+json ]]
[[ $CHILD_DIGEST != "$INDEX_DIGEST" ]]
CONFIG_DIGEST=$(jq -er .config.digest "$LAB/child.json")
printf 'CHILD=%s MEDIA=%s CONFIG=%s LAYERS=%s\n' \
  "$CHILD_DIGEST" "$(jq -r .mediaType "$LAB/child.json")" \
  "$CONFIG_DIGEST" "$(jq '.layers | length' "$LAB/child.json")"

A manifest describes a config object and ordered filesystem layers. This inspection downloads the small JSON manifest but not the layer tarballs, so it is a pre-layer-pull admission test. Quarkslab’s OCI specification deep dive is useful background for that descriptor graph.

Cross-check the config blob’s embedded platform

Do not stop after trusting the index annotation. The selected child points to a content-addressed config JSON that independently declares os and architecture. Fetch that one small blob, follow the registry’s redirect, hash it, and compare its fields with the requested target.

curl -LfsS -H "Authorization: Bearer $TOKEN" \
  "$REGISTRY/v2/$REPO/blobs/$CONFIG_DIGEST" -o "$LAB/config.json"
CONFIG_HASH=sha256:$(sha256sum "$LAB/config.json" | awk '{print $1}')

[[ $CONFIG_HASH == "$CONFIG_DIGEST" ]]
[[ $(jq -r .os "$LAB/config.json") == "$TARGET_OS" ]]
[[ $(jq -r .architecture "$LAB/config.json") == "$TARGET_ARCH" ]]
printf 'CONFIG_HASH=%s CONFIG_PLATFORM=%s/%s CREATED=%s\n' \
  "$CONFIG_HASH" "$(jq -r .os "$LAB/config.json")" \
  "$(jq -r .architecture "$LAB/config.json")" "$(jq -r .created "$LAB/config.json")"

A mismatch between the index platform annotation and the child config is a publisher-side integrity problem for release purposes, even when every individual digest verifies. Preserve the evidence and reject the release instead of guessing which field the runtime will prioritize.

Decide whether the release pins the index or one child

Re-fetch the index by immutable digest to prove that the registry returns the same negotiated bytes. Then mutate only a local child copy by appending one newline. Its changed hash demonstrates why a digest is an exact content identity rather than a label attached to equivalent-looking JSON.

curl -fsS -D "$LAB/recheck.headers" \
  -H "Authorization: Bearer $TOKEN" -H "Accept: $ACCEPT" \
  "$REGISTRY/v2/$REPO/manifests/$INDEX_DIGEST" -o "$LAB/recheck-index.json"
RECHECK_DIGEST=$(awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {gsub("\r", ""); print $2}' "$LAB/recheck.headers")
RECHECK_HASH=sha256:$(sha256sum "$LAB/recheck-index.json" | awk '{print $1}')
[[ $RECHECK_DIGEST == "$INDEX_DIGEST" ]]
[[ $RECHECK_HASH == "$INDEX_DIGEST" ]]

cp "$LAB/child.json" "$LAB/child-mutated.json"
printf '\n' >> "$LAB/child-mutated.json"
MUTATED_HASH=sha256:$(sha256sum "$LAB/child-mutated.json" | awk '{print $1}')
[[ $MUTATED_HASH != "$CHILD_DIGEST" ]]

jq -n --arg tag "$REPO:$TAG" --arg index "$INDEX_DIGEST" \
  --arg platform "$TARGET_OS/$TARGET_ARCH" --arg child "$CHILD_DIGEST" \
  --arg changed "$MUTATED_HASH" \
  '{tag:$tag,indexDigest:$index,targetPlatform:$platform,platformDigest:$child,localMutationDigest:$changed}'

Use the receipt to make an explicit release decision. Open Sourcerers’ manifest and digest explainer supplies additional background on why multi-architecture tags produce more than one image identity:

  • Pin repository@indexDigest when the approved artifact set must remain multi-platform and the runtime or scheduler will select per node.
  • Pin repository@platformDigest when the release is intentionally restricted to one target and that restriction is enforced elsewhere.
  • Record both digests either way. The index proves which platform set was reviewed; the child proves what one target will execute.

For multi-platform builds, cache ownership is a different operational problem. BuildKit cache pressure and safe pruning explains how to identify and reclaim builder-owned data without treating registry admission as disk cleanup.

Below is the representative receipt from the reproduced run:

HOST_ARCH=x86_64 CURL=curl JQ=jq-1.7
INDEX=sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce TOTAL_DESCRIPTORS=16
RUNNABLE=8 ATTESTATIONS=8 TARGET=linux/amd64 CHILD=sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6 UNSUPPORTED_MIPS64LE=0
CHILD=sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6 MEDIA=application/vnd.oci.image.manifest.v1+json CONFIG=sha256:b66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365 LAYERS=1
CONFIG_HASH=sha256:b66e0ce64844f5c6435b0c4bfd965558199ab0f53270846861c979cb1ac29365 CONFIG_PLATFORM=linux/amd64 CREATED=2026-06-22T19:20:21.712285437Z
{
  "tag": "library/alpine:3.22",
  "indexDigest": "sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce",
  "targetPlatform": "linux/amd64",
  "platformDigest": "sha256:7c8cb692ae09657cbc4a3f3cbd0e8d5a2690ba38386aaaf252dbb060bf5eb2e6",
  "localMutationDigest": "sha256:c766c928fbaf395385650476d533d8f255c6ec1d5a2d6481fbc73d3217f91cd4"
}
CLEANUP=complete PATH=/tmp/voxfor-oci-platform-143-lab

Turn the receipt into a fail-closed release gate

Those Alpine digests are observations from 2026-08-12, not constants to paste into a future release. A tag may move. Run the gate for the exact registry, repository, tag, target triple, and deployment window being approved.

Accept only when the response is a supported index media type; the index header digest equals the exact response bytes; exactly one runnable descriptor matches OS, architecture, and variant; the child header and bytes equal that descriptor digest; the config blob hash and embedded platform agree; and the release receipt retains both index and child digests. After deployment, still require workload-specific startup and application checks: platform compatibility cannot prove entrypoint, dependency, network, or health behavior.

That final distinction prevents a false conclusion. Docker healthcheck and restart-policy diagnosis begins after the runtime has accepted the artifact; pre-pull inspection cannot replace it. Likewise, Docker versus Docker Compose on a VPS helps choose the deployment surface after artifact admission, not the platform identity itself.

If any identity or platform assertion fails, stop before deployment, preserve the headers and JSON as a failed receipt, and keep the previous approved image reference active. If a later canary or workload check fails, restore that previous reference through the release system’s normal rollback, then investigate the publisher or application separately; do not rewrite registry metadata or substitute another child merely to make the gate pass.

Clean only the owned inspection fixture

Registry operations here are read-only, but the local receipt can contain repository metadata and authorization headers. The cleanup block unsets the token, verifies the exact marker, removes only regular files beneath the owned directory, removes the now-empty directory, and proves absence. It does not use a recursive deletion command.

unset TOKEN
[[ $(cat "$MARKER") == voxfor-oci-platform-143 ]]
find "$LAB" -xdev -type f -delete
rmdir "$LAB"
[[ ! -e $LAB ]]
printf 'CLEANUP=complete PATH=%s\n' "$LAB"

In CI, save a redacted copy of the result outside $LAB before cleanup. Keep digests, timestamps, repository, tag, target triple, media types, assertion outcomes, and tool versions. Do not retain bearer tokens or full authorization headers in ordinary build artifacts.

FAQ: OCI platform inspection decisions

Does an image tag identify one image?

Not necessarily. A tag can resolve to a single manifest or to an OCI image index or Docker manifest list containing multiple platform-specific child manifests. Inspect the negotiated media type before assuming which object you received.

Is the index digest the same as the platform digest?

No. The index digest identifies the descriptor set and therefore the approved platform menu. The platform digest identifies one child manifest. The reproduced Alpine response proved that the two values differ.

Why did this index contain unknown/unknown descriptors?

In the observed response, eight unknown/unknown descriptors accompanied runnable platform manifests as attestation or artifact objects. Other registries and publishers can structure related artifacts differently. Select the exact known target triple and inspect unexpected descriptors instead of globally classifying every unknown entry.

Can docker manifest inspect replace the Registry API commands?

It is useful for interactive discovery, but the raw API workflow preserves the negotiated media type, Docker-Content-Digest header, exact response bytes, child object, config object, and negative assertions in one machine-checkable receipt. A release gate can still wrap an equivalent trusted client if it proves the same invariants.

Does a matching architecture guarantee that the container will run?

No. It removes one class of mismatch. An image can still fail because of OS incompatibility, entrypoint format, missing interpreter, line endings, CPU features, dynamic libraries, configuration, or application health. Architecture-mismatch exec format error troubleshooting illustrates one downstream symptom; use a platform gate before pull and a workload acceptance test after deployment.

Should a heterogeneous cluster pin the index or the child?

Pin the index when the same release should resolve to different approved children on different node architectures. Pin one child only when scheduling and policy deliberately restrict the workload to that platform. In both cases, record the selected child observed during admission.

What should happen when the requested platform is absent?

Reject the release before layer transfer. Preserve the index receipt, confirm that OS, architecture, and variant use OCI vocabulary, and ask the publisher for a correct multi-platform build. Emulation can be a deliberate build or test strategy, but it should not silently convert absence into production approval.

Leave a Reply

Your email address will not be published. Required fields are marked *