How Container Images Are Structured: Manifest, Config, and Layers
A guided tour of the OCI image format: the manifest, the config JSON, content-addressed layers, and how a registry pull actually works.
Published on • October 15, 2026
AI Assistant

“An image” sounds like a single file. It isn’t. Pull an image apart and you’ll find three distinct kinds of content: a small JSON manifest that acts as an index, a small JSON config that describes how to run the thing, and a set of layers — tarred filesystem diffs — that carry the actual bytes. Everything is content-addressed by cryptographic digest, and that one design choice is what makes layer deduplication, integrity verification, and resumable transfers fall out naturally.
This is the OCI image format, and the registry protocol that moves it around is the OCI Distribution Spec. The CNCF Distribution registry — the reference implementation — implements OCI Distribution Spec 1.0.1, and it’s the same protocol Docker Hub, ECR, GCR, and friends speak.
The three pieces
Layers: the filesystem, one diff at a time
A layer is a tar archive (usually gzip-compressed) containing the files added, changed, or deleted relative to the layer below it. Layers stack bottom-to-top to reconstruct a root filesystem. Each layer is stored as a blob in the registry and is identified by its digest — a sha256: hash of the bytes. Same bytes anywhere in the registry, same digest, stored once.
The config: the runtime contract
The config is a small JSON blob describing how to turn those layers into a running process: environment variables, the default command and arguments, the working directory, the user to run as, exposed ports, and volumized paths. This is why docker run my-image needs no extra arguments — the config carries the command, args, env, and entrypoint. It also records history entries per build step and the ordered list of layer digests (rootfs.diff_ids) that make up the image.
The manifest: the index
The manifest ties it together. It’s a JSON document listing a descriptor for the config blob and an ordered list of descriptors for the layer blobs. A descriptor is a triple: mediaType (is this a config, an application/vnd.oci.image.layer.v1.tar+gzip, a nested manifest?), digest, and size. The manifest itself is also a blob with its own digest — that’s the sha256:... you get back from docker pull and pin in production deployments.
For multi-arch images there’s one more wrinkle: an image index (formerly the Docker manifest list) that points to several platform-specific manifests — linux/amd64, linux/arm64, and so on — plus optional attestation manifests. The client picks the right one for its platform.
Content addressing is the whole trick
The Distribution spec is explicit: the API design “is driven heavily by content addressability.” A digest is algorithm:hex:
digest := algorithm ":" hex
sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b
Two consequences matter in practice:
- Integrity for free. You can fetch content from an untrusted source, recompute the digest locally, and be certain you got the right bytes. Responses carry a
Docker-Content-Digestheader, but the spec warns clients to always verify against the digest they requested with, not blindly trust the header. - Deduplication for free. Two images built from the same base share layer blobs by digest. Pushing an image you already have mostly skips the transfer.
How a pull actually works
Here’s the wire protocol, straight from the HTTP API V2 spec. The registry is a plain HTTP service with a namespaced URL layout: everything lives under /v2/<name>/.
- Version check.
GET /v2/returns200 OKwith theDocker-Distribution-API-Version: registry/2.0header if the registry speaks V2. A401means authenticate (usually a token dance driven byWWW-Authenticate); a404means this isn’t a V2 registry. - Fetch the manifest.
GET /v2/<name>/manifests/<reference>where<reference>is a tag or a digest. The client sends anAcceptheader listing the manifest media types it understands — this is how a modern client asks for an OCI manifest or an image index and avoids falling back to the ancient schema-1 format. - Verify and walk. The client reads the manifest, pulls the config blob via
GET /v2/<name>/blobs/<digest>, then pulls each layer blob the same way. Layer endpoints may307redirect to object storage, and clients must handle that.Rangerequests support resuming interrupted downloads. - Unpack. Each layer is verified against its digest and unpacked into the local image store, stacked per the union-filesystem model.
You can watch this happen yourself against a local registry:
$ docker run -d -p 5000:5000 --name registry registry:3
$ docker pull ubuntu
$ docker tag ubuntu localhost:5000/myfirstimage
$ docker push localhost:5000/myfirstimage
# Now interrogate it directly:
$ curl -s http://localhost:5000/v2/myfirstimage/tags/list
{"name":"myfirstimage","tags":["latest"]}
$ curl -s -H "Accept: application/vnd.oci.image.manifest.v1+json" \
http://localhost:5000/v2/myfirstimage/manifests/latest
{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json",
"config":{"mediaType":"application/vnd.oci.image.config.v1+json",
"digest":"sha256:...","size":1234},
"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip",
"digest":"sha256:...","size":...}]}
How a push works (in reverse)
Push is layers-first, manifest-last — the manifest is what makes the image “exist”:
POST /v2/<name>/blobs/uploads/initiates a resumable upload, returning aLocationURL.PATCHsends chunks; the non-standard use ofRangetracks progress, and a416tells the client to resume from the last valid offset.PUT ...?digest=<digest>completes the upload; the registry verifies the bytes match the digest.- Finally
PUT /v2/<name>/manifests/<reference>uploads the manifest. If any referenced blob is missing, you getBLOB_UNKNOWN.
Before uploading a blob, a smart client sends HEAD /v2/<name>/blobs/<digest> — if the registry already has it, skip. There’s even a cross-repository mount: POST /v2/<name>/blobs/uploads/?mount=<digest>&from=<other-repo> links an existing blob into a new repository without any transfer.
Running your own registry
Distribution is a stateless, highly scalable server with pluggable storage drivers: the local filesystem for dev, and S3, Azure Blob, or Google Cloud Storage for production. It’s the CI/CD pattern the docs describe: commit triggers a build, the build pushes to your registry, a notification triggers a deployment — and it’s the way to distribute images inside an isolated network where Docker Hub isn’t reachable.
Practical gotchas
- Tags are mutable; digests are not.
myapp:latestcan point somewhere different tomorrow. Pin deployments tomyapp@sha256:...when you need reproducibility. - Deleting a blob that’s still referenced breaks the image. Distribution garbage collection reclaims unreferenced blobs — but only unreferenced ones. Delete the manifest, then GC.
- Manifest media types matter. When deleting a manifest on registry 2.3+, the docs note you must send the right
Acceptheader to get the digest the server actually wants deleted. Acceptnegotiation isn’t optional. A client that doesn’t advertise modern manifest types can be served legacy schema-1 manifests, which have the signature-embedding quirks the V2 spec was written to fix.- Layer count has a cost. Every layer is a separate blob with its own request and digest check; extremely deep layer stacks make pulls chattier.
- The 401 dance is normal. Anonymous pulls get a
WWW-Authenticatechallenge, then re-request with a bearer token. If your tooling assumes a single request, auth-protected registries will confuse it.