We push and pull container images every day. docker push shows a few progress bars, and the image is in the registry. docker pull brings it back. For most of us, it’s a black box, and it works fine until something goes wrong.
Have you ever compared the IDs? podman build prints one ID. The registry knows the image by a different digest. And the push log shows two more digests, but the registry has no blobs with these names. Which one is “the image”? Or a push fails with DIGEST_INVALID or MANIFEST_INVALID, and it’s not clear what the registry didn’t like.
I wanted to see what really goes over the network and what the registry keeps. So I took an image apart with curl, and then I built and pushed a new one by hand, without docker build or docker push. It turned out to be much simpler than I expected.
In this article, I show it step by step. But first, a short picture of what an image is.
What is inside an image
An image is just a few files:
- a manifest: a small JSON file, the table of contents of the image;
- a config: another JSON file with the command to run, the environment variables and the list of layers;
- layers: tar.gz archives with the files.
Each of these files is a blob, and each blob is named by the sha256 of its bytes, its digest. A registry stores blobs and keeps a list of tags. A tag points to a manifest, and the manifest points to the config and the layers:
tag 1.0 ──► manifest sha256:797fc2fc…
├─ config ──► sha256:118b309e… what to run, the layers' diff_ids
└─ layers ──► sha256:4fdc56a1… alpine (tar.gz)
──► sha256:0c2a525b… our app.sh (tar.gz)
Push and pull are a few plain HTTP requests. All of this is described in two open standards: the OCI image spec (the files) and the OCI distribution spec (the HTTP API). Docker Hub, GitHub Container Registry, Harbor, Amazon ECR and other registries speak the same API.
Why zot
zot is an OCI registry in a single binary. Two things make it perfect for learning:
- It keeps images on disk as plain files, in the standard OCI image layout.
- It logs every request as a line of JSON.
So we can see every idea twice: through the API with curl, and as files on disk. With a cloud registry, you only see the API.
Here are the tools I used:
| Tool | Version | What it does here |
|---|---|---|
| zot | 2.1.21 | the registry |
| podman | 5.8.7 | builds and pushes the first image, runs the last one |
| curl | 8.18.0 | every request by hand |
| jq | 1.8.1 | reads and writes JSON |
| tar, gzip, sha256sum, unshare | layers, digests and a chroot without root |
I use podman, but the registry part is the same for Docker. zot is a prebuilt binary on GitHub, so I installed it with mise, the same way as Git in my previous post:
# mise.toml
[tools]
"github:project-zot/zot" = { version = "2.1.21", asset_pattern = "zot-linux-amd64", bin = "zot" }
mise trust
mise install
Step by step
Every step below is recorded in a real terminal. My prompt shows the current directory: registry (where zot lives), shop (our project) and, at the very end, data (zot’s storage).
The digests are the same on every run here, because the build is reproducible. On your machine, they can be different. Upload IDs and dates are different on every run, and this is normal.
Step 1: Start a local registry
zot needs only two things in its config: a directory for the storage and an address.
{
"storage": { "rootDirectory": "./data" },
"http": { "address": "127.0.0.1", "port": "5000" }
}
I start it in the background and write its log to a file. We will need this log in step 3. A registry is just an HTTP server, and the whole API lives under /v2/. The catalog (the list of repositories) is empty for now:
$ zot serve zot.json > zot.log 2>&1 &
$ curl -s localhost:5000/v2/_catalog | jq -c .
{"repositories":[]}
Step 2: Build a tiny image and push it
The app is a one-line shell script on top of alpine 3.24.2:
#!/bin/sh
echo "shop 1.0: serving orders on :8080"
FROM docker.io/library/alpine:3.24.2
COPY app.sh /app.sh
CMD ["/app.sh"]
Two build flags need a few words. --timestamp fixes the dates inside the image, so the digests are the same on every build. --inherit-annotations=false keeps alpine’s annotations out of our manifest, so it’s short and easy to read. Our zot speaks plain HTTP, so the push needs --tls-verify=false.
$ podman build -q --timestamp 1767225600 --inherit-annotations=false \
-t localhost:5000/shop:1.0 .
118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd339295a0d59
$ podman push --tls-verify=false localhost:5000/shop:1.0
Getting image source signatures
Copying blob sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760701b0b7f711
Copying blob sha256:821e494ea1928185e87671633ae1fff5c32a380fe405c318c5b138136425fd0e
Copying config sha256:118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd339295a0d59
Writing manifest to image destination
$ curl -s localhost:5000/v2/shop/tags/list | jq -c .
{"name":"shop","tags":["1.0"]}
Remember the IDs that podman printed: the image ID 118b309e… and the two blobs 74d97c42… and 821e494e…. They will come back later.
Step 3: What podman push sent
zot logs every request as a line of JSON. We take only podman’s requests (its user agent starts with containers/) and print the status, the method and the path:
$ grep containers/ ../registry/zot.log \
| jq -r '"\(.statusCode) \(.method) \(.path)"' | cut -c1-84
200 GET /v2/
404 HEAD /v2/shop/blobs/sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760
202 POST /v2/shop/blobs/uploads/
202 PATCH /v2/shop/blobs/uploads/6d0d0ede-96c4-4651-9252-dad152a204ad
201 PUT /v2/shop/blobs/uploads/6d0d0ede-96c4-4651-9252-dad152a204ad?digest=sha256%3A
404 HEAD /v2/shop/blobs/sha256:821e494ea1928185e87671633ae1fff5c32a380fe405c318c5b13
202 POST /v2/shop/blobs/uploads/
202 PATCH /v2/shop/blobs/uploads/ab12f896-52a7-429c-97d7-5442c2704ab6
201 PUT /v2/shop/blobs/uploads/ab12f896-52a7-429c-97d7-5442c2704ab6?digest=sha256%3A
404 HEAD /v2/shop/blobs/sha256:118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd
202 POST /v2/shop/blobs/uploads/
202 PATCH /v2/shop/blobs/uploads/8083549e-2af0-4dd2-865d-85e453862c7f
201 PUT /v2/shop/blobs/uploads/8083549e-2af0-4dd2-865d-85e453862c7f?digest=sha256%3A
201 PUT /v2/shop/manifests/1.0
That’s the whole push. First, a ping (GET /v2/). Then, for every blob, podman asks HEAD: “do you have this blob?”. zot answers 404: no. So podman uploads it in three requests: POST, PATCH and PUT. Two layers and one config. The manifest goes last, with PUT /v2/shop/manifests/1.0. Later, we will send the same requests by hand.
Step 4: The manifest
A tag points to a manifest. With the Accept header, we tell the registry which manifest format we can read, because a registry can hold several formats. tee saves the raw bytes to a file before jq makes them pretty. This is important for the next step.
$ curl -s localhost:5000/v2/shop/manifests/1.0 \
-H 'Accept: application/vnd.oci.image.manifest.v1+json' | tee manifest.json | jq .
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd339295a0d59",
"size": 1060
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:4fdc56a1d49fc749f4b6d53e468a046cd1a019e7b108b1e5c954a43548e3997d",
"size": 3969967
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:0c2a525b4b84882622b95acc39fdc18e8c4215d8b133c3b7cc9e3698e061ef23",
"size": 150
}
],
"annotations": {
"org.opencontainers.image.created": "2026-01-01T00:00:00Z"
}
}
One config and two layers. Each entry has a media type, a digest and a size. This is called a descriptor, and this is how OCI points to anything.
Step 5: A digest is the sha256 of the bytes
With -I, curl shows only the headers. The registry names the manifest by its digest, in Docker-Content-Digest:
$ OCI_MANIFEST=application/vnd.oci.image.manifest.v1+json
$ curl -sI localhost:5000/v2/shop/manifests/1.0 -H "Accept: $OCI_MANIFEST"
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Authorization,content-type,X-ZOT-API-CLIENT
Access-Control-Allow-Methods: HEAD,GET,DELETE,OPTIONS
Access-Control-Allow-Origin: *
Content-Length: 634
Content-Type: application/vnd.oci.image.manifest.v1+json
Docker-Content-Digest: sha256:797fc2fcfdf9a0c59b0502e591fe6c9e37ae7f231ff6179f80c83a24aef4b0bd
Date: Thu, 01 Oct 2026 10:38:17 GMT
A small helper prints the sha256 of a file in the same format, and we get the same digest. Now let’s pretty-print the same JSON with jq and hash it. The digest is different, because the bytes are different. A digest pins bytes, not meaning.
$ digest() { echo "sha256:$(sha256sum "$@" | cut -d" " -f1)"; }
$ digest manifest.json
sha256:797fc2fcfdf9a0c59b0502e591fe6c9e37ae7f231ff6179f80c83a24aef4b0bd
$ jq . manifest.json | digest
sha256:c5b3713f6da7cabdcd0b534862e0fc3e1e4a1630aa5f2838e2939e53e586b7da
$ MANIFEST=$(digest manifest.json)
$ curl -s localhost:5000/v2/shop/manifests/$MANIFEST -H "Accept: $OCI_MANIFEST" | digest
sha256:797fc2fcfdf9a0c59b0502e591fe6c9e37ae7f231ff6179f80c83a24aef4b0bd
The last command fetches the manifest by digest instead of by tag. This way, you always get exactly the same bytes. A tag can be moved to another image, a digest can’t. This is why it’s a good idea to pin images by digest in production.
Step 6: The config
Every blob is fetched by its digest from /v2/<repository>/blobs/<digest>. The config is one of them:
$ CONFIG=$(jq -r .config.digest manifest.json)
$ curl -s localhost:5000/v2/shop/blobs/$CONFIG | tee config.json | digest
sha256:118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd339295a0d59
$ jq '{os, architecture, config, rootfs}' config.json
{
"os": "linux",
"architecture": "amd64",
"config": {
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
],
"Cmd": [
"/app.sh"
],
"WorkingDir": "/"
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760701b0b7f711",
"sha256:821e494ea1928185e87671633ae1fff5c32a380fe405c318c5b138136425fd0e"
]
}
}
The config says what to start (Cmd, Env, WorkingDir) and lists the layers by their diff_ids. There is also a history, but nothing checks it.
Now look at the digest of the config: 118b309e…. This is the image ID that podman build printed in step 2. For podman, the image ID is the digest of the config, not of the manifest. This is the first mystery from the beginning of the article.
Step 7: A layer is a tar.gz
The big layer (3.9 MB) is alpine, and the small one (150 bytes) is ours. It’s a normal tar.gz with app.sh inside:
$ jq -r '.layers[] | "\(.digest) \(.size) bytes"' manifest.json
sha256:4fdc56a1d49fc749f4b6d53e468a046cd1a019e7b108b1e5c954a43548e3997d 3969967 bytes
sha256:0c2a525b4b84882622b95acc39fdc18e8c4215d8b133c3b7cc9e3698e061ef23 150 bytes
$ LAYER=$(jq -r ".layers[1].digest" manifest.json)
$ curl -s localhost:5000/v2/shop/blobs/$LAYER | tee layer.tar.gz | digest
sha256:0c2a525b4b84882622b95acc39fdc18e8c4215d8b133c3b7cc9e3698e061ef23
$ tar -tvzf layer.tar.gz
-rwxr-xr-x 0/0 51 2026-01-01 00:00 app.sh
$ tar -xzOf layer.tar.gz app.sh
#!/bin/sh
echo "shop 1.0: serving orders on :8080"
$ zcat layer.tar.gz | digest
sha256:821e494ea1928185e87671633ae1fff5c32a380fe405c318c5b138136425fd0e
So a layer has two digests:
- the manifest has the digest of the compressed bytes (
0c2a525b…). This is what goes over the network and what the registry stores; - the config has the digest of the uncompressed tar (
821e494e…). This is thediff_id.
And 821e494e… is exactly what podman said it was copying in step 2. podman knows its layers by the uncompressed digest and compresses them during the push. This is the second mystery: the push log shows the uncompressed digests, but the registry stores the blobs under the compressed ones.
Step 8: zot’s disk
Now the same image from the other side, as files. Each repository is a plain directory:
blobs/sha256/with one file per blob, named after its digest;index.jsonwith the tags: our tag1.0points to the manifest797fc2fc…;oci-layout, which has just a version number.
$ cd ../registry
$ find data/shop -type f | sort
data/shop/blobs/sha256/0c2a525b4b84882622b95acc39fdc18e8c4215d8b133c3b7cc9e3698e061ef23
data/shop/blobs/sha256/118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd339295a0d59
data/shop/blobs/sha256/4fdc56a1d49fc749f4b6d53e468a046cd1a019e7b108b1e5c954a43548e3997d
data/shop/blobs/sha256/797fc2fcfdf9a0c59b0502e591fe6c9e37ae7f231ff6179f80c83a24aef4b0bd
data/shop/index.json
data/shop/oci-layout
$ sha256sum ../shop/manifest.json ../shop/config.json ../shop/layer.tar.gz
797fc2fcfdf9a0c59b0502e591fe6c9e37ae7f231ff6179f80c83a24aef4b0bd ../shop/manifest.json
118b309e8185ab81e6bc5187d3dc128800941182e1b59d019c6bd339295a0d59 ../shop/config.json
0c2a525b4b84882622b95acc39fdc18e8c4215d8b133c3b7cc9e3698e061ef23 ../shop/layer.tar.gz
The three files we downloaded with curl have exactly the names of three blobs. The fourth blob is alpine’s layer. That’s the whole repository.
Step 9: Pull by hand
A pull goes the same way: the manifest, the config, then every layer, unpacked in order into one directory. The result is alpine’s root filesystem with our app.sh on top.
$ cd ../shop
$ mkdir rootfs
$ for layer in $(jq -r ".layers[].digest" manifest.json); do
curl -s localhost:5000/v2/shop/blobs/$layer | tar -xz -C rootfs
done
$ ls rootfs | xargs
app.sh bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
$ cat rootfs/etc/alpine-release rootfs/app.sh
3.24.2
#!/bin/sh
echo "shop 1.0: serving orders on :8080"
$ unshare -r chroot rootfs $(jq -r ".config.Cmd[]" config.json)
shop 1.0: serving orders on :8080
To run it, I don’t even need a container engine. A chroot and the command from the config are enough. unshare -r makes me root in my own user namespace, so chroot works without sudo. Of course, a real container runtime does much more: namespaces, cgroups, the environment variables, the user. But the image part is exactly this.
Step 10: Pack a new layer
Now the other direction. Here is version 2.0 of the app, in v2/app.sh:
#!/bin/sh
echo "shop 2.0: packed with tar, pushed with curl"
A layer is just a tar archive, gzipped. A fixed owner and date, and gzip -n (no file name and time in the gzip header), keep the digest the same on every run.
$ chmod +x v2/app.sh
$ tar -C v2 --owner=0 --group=0 --mtime=@1767225600 -c app.sh \
| gzip -n > layer-2.0.tar.gz
$ tar -tvzf layer-2.0.tar.gz
-rwxr-xr-x root/root 61 2026-01-01 00:00 app.sh
$ LAYER2=$(digest layer-2.0.tar.gz)
$ echo $LAYER2
sha256:22b8f1544dece4c0a7cc40eebbc6f414ceff8310210dc4c208aeca86fa67e20f
$ wc -c layer-2.0.tar.gz
166 layer-2.0.tar.gz
Step 11: Upload it: POST opens, PATCH sends
An upload is a session. POST opens it. zot answers 202 Accepted, and the Location header says where to send the bytes:
$ curl -si -X POST localhost:5000/v2/shop/blobs/uploads/ | tee post.txt
HTTP/1.1 202 Accepted
Location: /v2/shop/blobs/uploads/65fa0bd4-53b9-4fb9-ab8a-804159d3bfec
Range: 0-0
Date: Thu, 01 Oct 2026 10:38:18 GMT
Content-Length: 0
PATCH sends the bytes. A big blob can be sent in several chunks, one PATCH per chunk. Range: 0-165 confirms that all 166 bytes have arrived. (tr -d '\r' is needed because HTTP header lines end with \r\n.)
$ UPLOAD=$(grep Location post.txt | tr -d '\r' | cut -d' ' -f2)
$ curl -si -X PATCH localhost:5000$UPLOAD --data-binary @layer-2.0.tar.gz \
-H "Content-Type: application/octet-stream"
HTTP/1.1 202 Accepted
Blob-Upload-Uuid: 65fa0bd4-53b9-4fb9-ab8a-804159d3bfec
Content-Length: 0
Location: /v2/shop/blobs/uploads/65fa0bd4-53b9-4fb9-ab8a-804159d3bfec
Range: 0-165
Date: Thu, 01 Oct 2026 10:38:18 GMT
$ wc -c ../registry/data/shop/.uploads/*
166 ../registry/data/shop/.uploads/65fa0bd4-53b9-4fb9-ab8a-804159d3bfec
On zot’s disk, the bytes wait in .uploads/, in a file named after the session. It isn’t a blob yet.
Step 12: PUT closes it, and zot checks the digest
PUT closes the session and names the digest of what we sent. zot hashes the bytes it has received and compares. Here, I make a classic mistake on purpose: I send the digest of the uncompressed tar.
$ DIFF_ID2=$(zcat layer-2.0.tar.gz | digest)
$ curl -s -X PUT "localhost:5000$UPLOAD?digest=$DIFF_ID2" | jq -c '.errors[] | {code, message}'
{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}
$ curl -si -X PUT "localhost:5000$UPLOAD?digest=$LAYER2"
HTTP/1.1 201 Created
Content-Length: 0
Docker-Content-Digest: sha256:22b8f1544dece4c0a7cc40eebbc6f414ceff8310210dc4c208aeca86fa67e20f
Location: /v2/shop/blobs/sha256:22b8f1544dece4c0a7cc40eebbc6f414ceff8310210dc4c208aeca86fa67e20f
Date: Thu, 01 Oct 2026 10:38:18 GMT
$ ls -A ../registry/data/shop/.uploads
$ curl -sI localhost:5000/v2/shop/blobs/$LAYER2 | head -1
HTTP/1.1 200 OK
zot keeps the session open after the error, so we just try again with the right digest: 201 Created. .uploads/ is empty now, and the blob exists.
Step 13: A new config and a new manifest
The uncompressed digest from the last step is still useful: it goes to the config as the new diff_id. Then the manifest gets the new config and the new layer, each with its digest and size.
$ jq -c --arg id $DIFF_ID2 '.rootfs.diff_ids[1] = $id' config.json > config-2.0.json
$ jq .rootfs config-2.0.json
{
"type": "layers",
"diff_ids": [
"sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760701b0b7f711",
"sha256:9a513fcb364386d864f47d09ebb192e12ebbcf9833d39c1d440abb20cfb06cdb"
]
}
$ CONFIG2=$(digest config-2.0.json)
$ jq --arg c $CONFIG2 --argjson cs $(wc -c < config-2.0.json) \
--arg l $LAYER2 --argjson ls $(wc -c < layer-2.0.tar.gz) \
'.config.digest = $c | .config.size = $cs
| .layers[1].digest = $l | .layers[1].size = $ls' manifest.json > manifest-2.0.json
$ jq -r '.config, .layers[] | "\(.digest) \(.size) bytes"' manifest-2.0.json
sha256:b318dccc661c9d4e3bad9efb23c5ca07facfad29990cba85b6b4d5d1e3ff37d0 1061 bytes
sha256:4fdc56a1d49fc749f4b6d53e468a046cd1a019e7b108b1e5c954a43548e3997d 3969967 bytes
sha256:22b8f1544dece4c0a7cc40eebbc6f414ceff8310210dc4c208aeca86fa67e20f 166 bytes
alpine’s layer stays as it is: the same digest, nothing to upload.
Step 14: The manifest goes last
Why does the manifest always go last? Let’s push it first and see:
$ curl -s -X PUT localhost:5000/v2/shop/manifests/2.0 \
-H "Content-Type: $OCI_MANIFEST" --data-binary @manifest-2.0.json \
| jq -c '.errors[] | {code, message}'
{"code":"MANIFEST_INVALID","message":"manifest invalid"}
$ jq -r 'select(.level == "error") | .message' ../registry/zot.log | tail -1
failed to stat blob due to missing config blob
zot refuses it. The error itself doesn’t say much, but zot’s log does: the config is missing. A registry checks that everything a manifest points to already exists.
A small blob like the config can be uploaded in one request: POST with ?digest= and the data. After that, the manifest is accepted. The tag goes in the URL, and the digest that comes back is the sha256 of the file we sent:
$ curl -si -X POST "localhost:5000/v2/shop/blobs/uploads/?digest=$CONFIG2" \
-H "Content-Type: application/octet-stream" --data-binary @config-2.0.json | head -1
HTTP/1.1 201 Created
$ curl -si -X PUT localhost:5000/v2/shop/manifests/2.0 \
-H "Content-Type: $OCI_MANIFEST" --data-binary @manifest-2.0.json
HTTP/1.1 201 Created
Docker-Content-Digest: sha256:161fc244e1ab8e750f2713d6ddeff053d8a5c6f836b115aba5b595c6ef6110f9
Location: /v2/shop/manifests/sha256:161fc244e1ab8e750f2713d6ddeff053d8a5c6f836b115aba5b595c6ef6110f9
Date: Thu, 01 Oct 2026 10:38:18 GMT
Content-Length: 0
$ digest manifest-2.0.json
sha256:161fc244e1ab8e750f2713d6ddeff053d8a5c6f836b115aba5b595c6ef6110f9
Step 15: podman runs what curl pushed
The moment of truth. podman pulls shop:2.0 and runs it. It still has alpine’s layer from the build (skipped: already exists), so it downloads only our layer and our config. For podman, it’s a completely normal image:
$ curl -s localhost:5000/v2/shop/tags/list | jq -c .
{"name":"shop","tags":["1.0","2.0"]}
$ podman run --rm --tls-verify=false localhost:5000/shop:2.0
...
shop 2.0: packed with tar, pushed with curl
$ ls ../registry/data/shop/blobs/sha256 | wc -l
7
zot holds seven blobs now: two manifests, two configs and three layers. Not four layers, because alpine’s layer is stored only once.
Step 16: Copy it to another repository without uploading
Let’s promote shop:2.0 to a prod repository. zot already has every blob, so there is nothing to upload. We just ask zot to mount them from shop with POST /v2/prod/blobs/uploads/?mount=<digest>&from=shop. Then we push the manifest, as before:
$ for blob in $(jq -r ".config.digest, .layers[].digest" manifest-2.0.json); do
curl -si -X POST "localhost:5000/v2/prod/blobs/uploads/?mount=$blob&from=shop" \
| head -1
done
HTTP/1.1 201 Created
HTTP/1.1 201 Created
HTTP/1.1 201 Created
$ curl -si -X PUT localhost:5000/v2/prod/manifests/2.0 \
-H "Content-Type: $OCI_MANIFEST" --data-binary @manifest-2.0.json | head -1
HTTP/1.1 201 Created
$ curl -s localhost:5000/v2/_catalog | jq -c .
{"repositories":["prod","shop"]}
$ cd ../registry/data
$ ls -1i */blobs/sha256/${LAYER2#sha256:}
94425 prod/blobs/sha256/22b8f1544dece4c0a7cc40eebbc6f414ceff8310210dc4c208aeca86fa67e20f
94425 shop/blobs/sha256/22b8f1544dece4c0a7cc40eebbc6f414ceff8310210dc4c208aeca86fa67e20f
201 Created right away: no session and no bytes. On disk, the layer in prod is the same file as the layer in shop: one inode, two names. podman does the same by itself when it knows that the registry already has a layer in another repository.
The whole API on one page
| Request | What it does |
|---|---|
GET /v2/<repo>/manifests/<tag or digest> | get the manifest (send Accept) |
GET /v2/<repo>/blobs/<digest> | get a config or a layer |
HEAD /v2/<repo>/blobs/<digest> | do you have this blob? |
POST /v2/<repo>/blobs/uploads/ | open an upload, returns Location |
PATCH <Location> | send the bytes, returns Range |
PUT <Location>?digest=<digest> | close the upload, the digest is checked |
POST /v2/<repo>/blobs/uploads/?digest=<digest> | upload a small blob in one request |
POST /v2/<repo>/blobs/uploads/?mount=<digest>&from=<repo> | link a blob from another repository |
PUT /v2/<repo>/manifests/<tag> | push the manifest, always last |
Things to know
A real client checks more. Our
forloop in step 9 trusts the registry. A real client checks the sha256 of every blob while it downloads it. It also handles deleted files: a file removed in a later layer appears in that layer as an empty.wh.<name>entry (a “whiteout”).Real registries ask for a token. Our zot has no authentication. Docker Hub answers the same request with
401and tells you where to get a token:$ curl -sI https://registry-1.docker.io/v2/library/alpine/manifests/3.24.2 HTTP/2 401 www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/alpine:pull"For public images, you can get a token without a login. Send it in the
Authorizationheader, and the API is the same as above:TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq -r .token) curl -s https://registry-1.docker.io/v2/library/alpine/manifests/3.24.2 \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.oci.image.index.v1+json" | jq '.manifests | length'Multi-platform images have one more level. On top of the manifests, there is an image index: a list of manifests, one per platform.
alpine:3.24.2on Docker Hub is an index with 16 entries: images for 8 platforms (amd64, arm64, s390x and others) and 8 attestations. podman picked the linux/amd64 manifest from it when it pulled alpine for our build.Nobody checks the history.
config-2.0.jsonstill has the history from the first build, and podman ran the image anyway.podman historyshows this history, but nothing verifies it.The Content-Type matters. The one-request upload needs
Content-Type: application/octet-stream. Without it, curl sendsapplication/x-www-form-urlencoded, and zot answers415 Unsupported Media Type.
I post practical DevOps and SRE notes, new tool features and small tricks in my Telegram channel DevOps & SRE notes.
Conclusion
A container image is not magic. It’s a few files: a manifest, a config and layers, each named by the sha256 of its bytes. A tag is just a name for a manifest digest. A layer has two digests: the compressed one in the manifest and the uncompressed one (the diff_id) in the config. The registry checks every blob against its digest and every manifest against its blobs, and this is why the manifest always goes last.
And docker push is just a few HTTP requests. curl, tar and jq can do the same, and podman runs the result. Next time a push fails with DIGEST_INVALID or MANIFEST_INVALID, or the image ID doesn’t match the digest in the registry, you know where to look.
Links: zot · OCI image spec · OCI distribution spec · OCI image layout