Series: Understanding OCI from the Ground Up (Part 2 of 5)
In Part 1 we built an OCI image by hand. Now we push it to a server and pull it back — without skopeo, without a real registry — justcurland an nginx server with WebDAV. This strips away every abstraction and reveals what the OCI Distribution Spec actually is: a REST API for uploading and downloading content-addressable blobs.
What is the OCI Distribution Spec?
The OCI Distribution Specification defines an HTTP API for storing and retrieving container images. Every docker push and docker pull is a series of HTTP requests to this API.
The core idea is simple:
A registry is a content-addressable blob store with an HTTP API. Push = Upload blobs (layers, config) + Upload manifest Pull = Download manifest + Download blobs (layers, config)
That's it. A registry is a file server with a specific URL scheme. To prove this, we'll use nginx as our "registry" — no Docker Registry, no Harbor, no ECR. Just nginx with WebDAV enabled.
Where Does index.json Go? Tags Replace It.
In Part 1, we stored our image as an OCI Image Layout — a directory on disk. The entry point was index.json:
{
"schemaVersion": 2,
"manifests": [{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:3a5fa5cc...",
"size": 675,
"annotations": {"org.opencontainers.image.ref.name": "v1"}
}]
}
This file answers: "Which manifests live here, and what are they called?" — it maps the human-readable name v1 to the digest sha256:3a5fa5cc....
When you push to a registry, index.json and oci-layout are never uploaded. The registry doesn't need them because tags in the URL replace index.json:
| On disk (OCI Layout) | On a registry (Distribution Spec) |
|---|---|
Read index.json to find "v1" → sha256:3a5fa5cc... | GET /v2/ubuntu-curl/manifests/v1 — the tag is in the URL |
index.json is the entry point | The manifest URL is the entry point |
Tags live in annotations | Tags live in the URL path |
One index.json per image directory | Registry maintains tag→digest mappings in a metadata store |
What you actually upload to a registry:
1. PUT /v2/ubuntu-curl/blobs/sha256:6edbc812... ← base layer 2. PUT /v2/ubuntu-curl/blobs/sha256:39cbd0bb... ← curl layer 3. PUT /v2/ubuntu-curl/blobs/sha256:d1f493ed... ← config 4. PUT /v2/ubuntu-curl/manifests/v1 ← manifest (tagged!)
Notice step 4: you PUT the manifest at a tag URL (/manifests/v1). The registry records that v1 points to this manifest's digest. From that moment, anyone can GET /v2/ubuntu-curl/manifests/v1 and receive the manifest — no index.json needed.
Think of it this way:
index.jsonis the table of contents for a local directory- Tags are the table of contents for a registry
- The registry itself is the index
This is why when you skopeo copy from a registry back to a local OCI layout, skopeo creates a fresh index.json from the tag you pulled — it reconstructs the local entry point from the registry's tag information.
The Setup: Two Containers on a Bridge Network
We run everything inside Docker Desktop for Mac. Two containers on a shared network:
┌─────────────────────┐ ┌─────────────────────┐
│ oci-lab │ HTTP │ oci-nginx │
│ (Ubuntu 22.04) │────────►│ (nginx + WebDAV) │
│ │ │ │
│ tools: curl, jq, │ │ Stores blobs at: │
│ skopeo, sha256sum │ │ /data/registry/v2/ │
│ │ │ │
│ Has the OCI image │ │ Listens on :80 │
│ from Part 1 │ │ │
└─────────────────────┘ └─────────────────────┘
oci-net (Docker bridge network)
Start the infrastructure
# Create an isolated network docker network create oci-net # Start nginx with our custom config docker run --rm -d --name oci-nginx --network oci-net \ -v $(pwd)/nginx-oci-registry.conf:/etc/nginx/conf.d/default.conf:ro \ nginx:latest # Create the storage directory docker exec oci-nginx mkdir -p /data/registry docker exec oci-nginx chmod 777 /data/registry # Start the lab container docker run --rm -d --name oci-lab --network oci-net --dns 8.8.8.8 \ ubuntu:22.04 sleep 3600 # Install tools docker exec oci-lab bash -c \ "apt-get update -qq && apt-get install -y -qq skopeo jq wget file curl xz-utils > /dev/null 2>&1"
The nginx Configuration
Here's the complete nginx config that turns a stock nginx into an OCI-compatible blob server:
server {
listen 80;
server_name localhost;
# ─────────────────────────────────────────────────
# OCI Distribution Spec: /v2/ version check
# Every registry must return 200 on GET /v2/
# ─────────────────────────────────────────────────
location = /v2/ {
default_type application/json;
return 200 '{"status": "ok"}';
add_header Docker-Distribution-API-Version registry/2.0 always;
}
# ─────────────────────────────────────────────────
# Tag listing: GET /v2/<name>/tags/list
#
# A real registry generates this dynamically.
# We serve a static JSON file created after pushing.
# ─────────────────────────────────────────────────
location ~ ^/v2/(.+)/tags/list$ {
alias /data/registry/v2/$1/tags/_list.json;
default_type application/json;
add_header Docker-Distribution-API-Version registry/2.0 always;
}
# ─────────────────────────────────────────────────
# Manifest storage: PUT & GET /v2/<name>/manifests/<ref>
#
# Manifests MUST be served with the correct
# Content-Type so that clients (skopeo, crane, etc.)
# can parse them. We use a dedicated location block.
# ─────────────────────────────────────────────────
location ~ ^/v2/.+/manifests/ {
root /data/registry;
dav_methods PUT DELETE;
create_full_put_path on;
dav_access user:rw group:r all:r;
client_max_body_size 10m;
# Serve manifests as OCI manifest JSON
default_type application/vnd.oci.image.manifest.v1+json;
add_header Docker-Distribution-API-Version registry/2.0 always;
}
# ─────────────────────────────────────────────────
# Blob storage: PUT & GET /v2/<name>/blobs/<digest>
#
# We use nginx's built-in WebDAV module (dav_methods)
# to accept PUT requests that create files on disk.
# This turns nginx into a dumb content-addressable
# file server — which is all a registry really is.
# ─────────────────────────────────────────────────
location /v2/ {
root /data/registry;
dav_methods PUT DELETE;
create_full_put_path on;
dav_access user:rw group:r all:r;
# Allow large blob uploads (layers can be 100s of MB)
client_max_body_size 200m;
# Serve blobs as binary by default
default_type application/octet-stream;
add_header Docker-Distribution-API-Version registry/2.0 always;
}
}
Key design decisions:
| Config Directive | Why |
|---|---|
dav_methods PUT DELETE | Enables file creation via HTTP PUT — the core of "pushing" |
create_full_put_path on | Auto-creates /v2/ubuntu-curl/blobs/ directory tree on first PUT |
client_max_body_size 200m | Layer blobs can be huge; default 1 MB limit would reject them |
root /data/registry | PUT to /v2/foo/blobs/sha256:abc stores file at /data/registry/v2/foo/blobs/sha256:abc |
| Separate manifests block | Manifests must be served with Content-Type: application/vnd.oci.image.manifest.v1+json so clients can parse them |
alias for tags/list | Serves a static JSON file so skopeo inspect and tag listing work |
Docker-Distribution-API-Version | Header that clients check to confirm this is a v2 registry |
This is not a production registry. It lacks authentication, garbage collection, chunked uploads, and content-type negotiation. But it implements enough of the Distribution Spec to demonstrate the core concepts: blobs go up, blobs come down, addressed by sha256 digest.
The Distribution Spec API
The OCI Distribution Spec defines these endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
GET | /v2/ | API version check |
PUT | /v2/<name>/blobs/<digest> | Upload a blob (layer or config) |
GET | /v2/<name>/blobs/<digest> | Download a blob |
HEAD | /v2/<name>/blobs/<digest> | Check if a blob exists |
PUT | /v2/<name>/manifests/<reference> | Upload a manifest (by tag or digest) |
GET | /v2/<name>/manifests/<reference> | Download a manifest |
In a real registry, blob uploads use a two-step process (POST to start, then PATCH/PUT to upload chunks). Our nginx simplifies this to a single PUT since we're uploading the entire blob at once.
Push: Uploading an Image with curl
We have the ubuntu-curl OCI image from Part 1. It contains:
| Blob | Type | Size | Digest |
|---|---|---|---|
| Base layer | tar+gzip | 27,606,543 bytes | sha256:6edbc812af48... |
| Curl layer | tar+gzip | 4,638,209 bytes | sha256:4a927cfe2d12... |
| Config | JSON | 2,585 bytes | sha256:11288e28f77c... |
| Manifest | JSON | 675 bytes | sha256:8058cc564c55... |
Step 1: Verify the registry is alive
curl -v http://oci-nginx/v2/
> GET /v2/ HTTP/1.1
> Host: oci-nginx
> User-Agent: curl/7.81.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: nginx/1.29.8
< Content-Type: application/json
< Content-Length: 16
< Docker-Distribution-API-Version: registry/2.0
<
{"status": "ok"}
The Distribution Spec requires that GET /v2/ returns 200 OK. This is how clients detect whether a server speaks the registry API. The Docker-Distribution-API-Version: registry/2.0 header confirms it.
Step 2: Push the base layer blob (27 MB)
curl -X PUT \ --data-binary @ubuntu-curl/blobs/sha256/6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a \ -H "Content-Type: application/vnd.oci.image.layer.v1.tar+gzip" \ "http://oci-nginx/v2/ubuntu-curl/blobs/sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a"
HTTP 201 Created | Uploaded 27,606,543 bytes | Time: 0.016s
What happened:
curlread the 27 MB layer blob from the local OCI layout- Sent an HTTP
PUTto the URL/v2/ubuntu-curl/blobs/sha256:6edbc812... - nginx created the directory tree
/data/registry/v2/ubuntu-curl/blobs/(thanks tocreate_full_put_path) - Wrote the blob to disk as a file named
sha256:6edbc812... - Returned
201 Created
Notice the URL structure: /v2/<image-name>/blobs/<digest>. The digest is the address. To retrieve this blob later, you use the exact same URL with GET.
Step 3: Push the curl layer blob (4.6 MB)
curl -X PUT \ --data-binary @ubuntu-curl/blobs/sha256/4a927cfe2d124d4f593a52f36aceb0d0a096c122ae3bfe929923cda2eb360d12 \ -H "Content-Type: application/vnd.oci.image.layer.v1.tar+gzip" \ "http://oci-nginx/v2/ubuntu-curl/blobs/sha256:4a927cfe2d124d4f593a52f36aceb0d0a096c122ae3bfe929923cda2eb360d12"
HTTP 201 Created | Uploaded 4,638,209 bytes | Time: 0.006s
Step 4: Push the config blob (2.5 KB)
curl -X PUT \ --data-binary @ubuntu-curl/blobs/sha256/11288e28f77c00494ba9080a662896077cc33cb0edba24a057b714e9a32ecd94 \ -H "Content-Type: application/vnd.oci.image.config.v1+json" \ "http://oci-nginx/v2/ubuntu-curl/blobs/sha256:11288e28f77c00494ba9080a662896077cc33cb0edba24a057b714e9a32ecd94"
HTTP 201 Created | Uploaded 2,585 bytes | Time: 0.001s
The verbose HTTP exchange:
> PUT /v2/ubuntu-curl/blobs/sha256:11288e28f77c00494ba9080a662896077cc33cb0edba24a057b714e9a32ecd94 HTTP/1.1 > Host: oci-nginx > User-Agent: curl/7.81.0 > Accept: */* > Content-Type: application/vnd.oci.image.config.v1+json > Content-Length: 2585 > < HTTP/1.1 201 Created < Server: nginx/1.29.8 < Docker-Distribution-API-Version: registry/2.0
Step 5: Push the manifest (tagged as v1)
The manifest is the last thing pushed. It references all the blobs, so they must exist first.
curl -X PUT \ --data-binary @ubuntu-curl/blobs/sha256/8058cc564c55c6e12dc1f0db65a08dc623fc0c70c570a7767b72733ecb6017b9 \ -H "Content-Type: application/vnd.oci.image.manifest.v1+json" \ "http://oci-nginx/v2/ubuntu-curl/manifests/v1"
HTTP 201 Created | Uploaded 675 bytes | Time: 0.001s
The manifest URL is different. Blobs use /v2/<name>/blobs/<digest> (addressed by content hash). Manifests use /v2/<name>/manifests/<reference> where <reference> can be a tag (v1, latest) or a digest.
Push complete — what's on the nginx filesystem?
docker exec oci-nginx find /data/registry -type f | sort
/data/registry/v2/ubuntu-curl/blobs/sha256:11288e28f77c... (2,585 bytes — config) /data/registry/v2/ubuntu-curl/blobs/sha256:4a927cfe2d12... (4,638,209 bytes — curl layer) /data/registry/v2/ubuntu-curl/blobs/sha256:6edbc812af48... (27,606,543 bytes — base layer) /data/registry/v2/ubuntu-curl/manifests/v1 (675 bytes — manifest)
Four files. That's the entire image stored in nginx. The directory structure is the API:
/data/registry/
└── v2/
└── ubuntu-curl/ ← image name
├── blobs/
│ ├── sha256:6edbc812af48... ← base layer (27.6 MB)
│ ├── sha256:4a927cfe2d12... ← curl layer (4.6 MB)
│ └── sha256:11288e28f77c... ← config (2.5 KB)
└── manifests/
└── v1 ← manifest tag (675 B)
Push order matters
1. Blobs first (layers + config) — they have no dependencies 2. Manifest last — it references all blobs by digest
If you push the manifest before its blobs exist, a real registry would reject it. Our nginx doesn't validate, but the convention exists for good reason: the manifest is a promise that all referenced blobs are available.
Pull: Downloading an Image with curl
Now pretend we're a fresh client. We know only the registry URL and the image name + tag.
Step 1: Check the API
curl http://oci-nginx/v2/
{"status": "ok"}
Step 2: Fetch the manifest by tag
curl -H "Accept: application/vnd.oci.image.manifest.v1+json" \ http://oci-nginx/v2/ubuntu-curl/manifests/v1
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 2585,
"digest": "sha256:11288e28f77c00494ba9080a662896077cc33cb0edba24a057b714e9a32ecd94"
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 27606543,
"digest": "sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a"
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:4a927cfe2d124d4f593a52f36aceb0d0a096c122ae3bfe929923cda2eb360d12",
"size": 4638209
}
]
}
The verbose HTTP exchange:
> GET /v2/ubuntu-curl/manifests/v1 HTTP/1.1 > Host: oci-nginx > Accept: application/vnd.oci.image.manifest.v1+json > < HTTP/1.1 200 OK < Server: nginx/1.29.8 < Content-Type: application/octet-stream < Content-Length: 675 < Docker-Distribution-API-Version: registry/2.0
The manifest is the roadmap. It tells us:
- Config is at digest
sha256:11288e28f77c...(2,585 bytes) - Layer 1 (Ubuntu base) is at
sha256:6edbc812af48...(27,606,543 bytes) - Layer 2 (curl binary) is at
sha256:4a927cfe2d12...(4,638,209 bytes)
Step 3: Fetch the config blob
Follow the config.digest from the manifest:
curl -s http://oci-nginx/v2/ubuntu-curl/blobs/sha256:11288e28f77c00494ba9080a662896077cc33cb0edba24a057b714e9a32ecd94 | jq '{
architecture, os, created,
config: {Cmd: .config.Cmd, Env: .config.Env},
rootfs
}'
{
"architecture": "arm64",
"os": "linux",
"created": "2026-04-25T07:46:23Z",
"config": {
"Cmd": ["/usr/local/bin/curl", "--help"],
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:caa9956c50e9facf65021733bc7890b61ce94076388533506c80c8ae1b603db4",
"sha256:47cf1befc2be616ed5314e7046fb449949e0d567c089cb5e6665c0363c9c88ee"
]
}
}
The verbose headers:
> GET /v2/ubuntu-curl/blobs/sha256:11288e28f77c00494ba9080a662896077cc33cb0edba24a057b714e9a32ecd94 HTTP/1.1 > Host: oci-nginx > Accept: */* > < HTTP/1.1 200 OK < Content-Type: application/octet-stream < Content-Length: 2585 < Docker-Distribution-API-Version: registry/2.0
Step 4: Download the layer blobs
# Base layer (27 MB) curl -s -o pulled-base-layer.tar.gz \ http://oci-nginx/v2/ubuntu-curl/blobs/sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a # Curl layer (4.6 MB) curl -s -o pulled-curl-layer.tar.gz \ http://oci-nginx/v2/ubuntu-curl/blobs/sha256:4a927cfe2d124d4f593a52f36aceb0d0a096c122ae3bfe929923cda2eb360d12
Base layer : HTTP 200 | Downloaded 27,606,543 bytes | Time: 0.007s Curl layer : HTTP 200 | Downloaded 4,638,209 bytes | Time: 0.002s
Step 5: Verify integrity (content-addressable roundtrip)
The killer feature of content-addressable storage: we can verify every download.
# Verify base layer sha256sum pulled-base-layer.tar.gz
6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a pulled-base-layer.tar.gz
# Verify curl layer sha256sum pulled-curl-layer.tar.gz
4a927cfe2d124d4f593a52f36aceb0d0a096c122ae3bfe929923cda2eb360d12 pulled-curl-layer.tar.gz
Both hashes match the digests in the manifest exactly. The data survived the roundtrip perfectly.
Step 6: Reconstruct the filesystem from pulled blobs
mkdir -p pulled-rootfs tar -xzf pulled-base-layer.tar.gz -C pulled-rootfs tar -xzf pulled-curl-layer.tar.gz -C pulled-rootfs
head -2 pulled-rootfs/etc/os-release
PRETTY_NAME="Ubuntu 22.04.5 LTS" NAME="Ubuntu"
file pulled-rootfs/usr/local/bin/curl
pulled-rootfs/usr/local/bin/curl: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), static-pie linked, stripped
pulled-rootfs/usr/local/bin/curl --version | head -1
curl 8.19.0 (aarch64-pc-linux-gnu) libcurl/8.19.0 OpenSSL/3.6.1 zlib/1.3.2 brotli/1.2.0 zstd/1.5.7
We just "pulled" and "ran" a container image using nothing but curl, tar, and sha256sum.
The Full Pull Sequence — Visualized
Client nginx (registry)
│ │
│──── GET /v2/ ───────────────────────────────►│
│◄─── 200 OK {"status":"ok"} ─────────────────│
│ │
│──── GET /v2/ubuntu-curl/manifests/v1 ──────►│
│◄─── 200 OK (675 bytes, manifest JSON) ──────│
│ │
│ [Parse manifest: find config + layer digests]
│ │
│──── GET /v2/ubuntu-curl/blobs/sha256:1128...►│
│◄─── 200 OK (2,585 bytes, config JSON) ──────│
│ │
│──── GET /v2/ubuntu-curl/blobs/sha256:6edb...►│
│◄─── 200 OK (27,606,543 bytes, layer tar.gz)─│
│ │
│──── GET /v2/ubuntu-curl/blobs/sha256:4a92...►│
│◄─── 200 OK (4,638,209 bytes, layer tar.gz) ─│
│ │
│ [Extract layers in order → rootfs]
│ [Read config → Cmd, Env, architecture]
│ [Container ready to run]
Total HTTP requests for a complete pull: 4 (version check + manifest + config + 2 layers). That's it. No magic protocols, no gRPC, no custom binary formats. Just HTTP GETs returning files.
How Real Registries Differ from Our nginx
Our nginx WebDAV setup implements the happy path. Real registries (Docker Hub, GitHub Container Registry, ECR, Harbor) add:
| Feature | Our nginx | Real Registry |
|---|---|---|
| Blob upload | Single PUT | Two-step: POST (get upload URL) → PATCH/PUT (stream data) |
| Chunked uploads | ✗ | ✓ (resume interrupted uploads) |
| Content validation | ✗ | ✓ (verify digest matches content on upload) |
| Tag listing | Static JSON file | GET /v2/<name>/tags/list (dynamic) |
| Authentication | None | Bearer token (OAuth2-style) |
| Manifest validation | ✗ | ✓ (reject manifest if blobs don't exist) |
| Garbage collection | ✗ | ✓ (delete unreferenced blobs) |
| Content negotiation | ✗ | ✓ (Accept header selects manifest format) |
The Distribution Spec also defines the chunked upload flow for large blobs:
POST /v2/<name>/blobs/uploads/ → 202 Accepted (Location: <upload-url>) PATCH <upload-url> → 202 Accepted (upload chunk) PUT <upload-url>?digest=sha256:<hash> → 201 Created (finalize, verify digest)
Our nginx collapses this into a single PUT. The principle is identical: put bytes at an address, get them back by that address.
Bonus: Validate with skopeo
We pushed everything with raw curl. Can a real OCI tool read it? Let's point skopeo at our nginx "registry".
First, after pushing, we create a tags list file (required for skopeo inspect):
# Create the tags list (a real registry does this dynamically)
docker exec oci-nginx mkdir -p /data/registry/v2/ubuntu-curl/tags
docker exec oci-nginx bash -c \
'echo "{\"name\":\"ubuntu-curl\",\"tags\":[\"v1\"]}" > /data/registry/v2/ubuntu-curl/tags/_list.json'
How do real registries generate tags dynamically?
When you PUT /v2/ubuntu-curl/manifests/v1, a real registry (like Docker Registry or Harbor) does more than just write the blob to disk. It updates an internal metadata store:
- Store the manifest by its sha256 digest (just like our nginx)
- Create a tag→digest mapping — record that
v1points tosha256:3a5fa5cc... - Update the tag list — append
v1to the list of known tags forubuntu-curl
When a client calls GET /v2/ubuntu-curl/tags/list, the registry queries this metadata store and returns:
{"name": "ubuntu-curl", "tags": ["v1", "v2", "latest"]}
Most registries use a key-value store or database for this (Docker Registry uses the filesystem at _manifests/tags/, Harbor uses PostgreSQL). The critical insight: a tag is just a mutable pointer to an immutable manifest digest. When you push a new image as latest, the registry simply updates the pointer — the old manifest and its blobs remain untouched until garbage collection deletes unreferenced content.
Our nginx can't do this because it has no logic layer — it's a dumb file server. So we create the _list.json file manually, which is enough for tools like skopeo to discover our tags.
skopeo inspect — read metadata from nginx
skopeo inspect --tls-verify=false docker://oci-nginx:80/ubuntu-curl:v1
{
"Name": "oci-nginx:80/ubuntu-curl",
"Digest": "sha256:3a5fa5cc98430f9e28254c14be3d021ca36a7ae4dd315604084df6a64f9d3c0d",
"RepoTags": [
"v1"
],
"Created": "2026-04-25T08:02:13Z",
"DockerVersion": "26.1.3",
"Labels": {
"org.opencontainers.image.version": "22.04"
},
"Architecture": "arm64",
"Os": "linux",
"Layers": [
"sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a",
"sha256:39cbd0bb3e72032b38fa9d83d3d3b715c3030281006bd76946a2f1a18de3f229"
],
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
]
}
Skopeo successfully parsed the manifest and config that we uploaded with curl. It sees two layers, arm64 architecture, and our custom Cmd.
skopeo copy — pull the full image from nginx
skopeo copy --src-tls-verify=false \ docker://oci-nginx:80/ubuntu-curl:v1 \ oci:pulled-via-skopeo:v1
Getting image source signatures Copying blob sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a Copying blob sha256:39cbd0bb3e72032b38fa9d83d3d3b715c3030281006bd76946a2f1a18de3f229 Copying config sha256:d1f493eddb008a8838f642e4053d311f1fbf969768326bc8da1c95a5fcf35186 Writing manifest to image destination Storing signatures
Skopeo pulled the image from our nginx server — fetching the manifest, then the config, then both layers — and wrote a valid OCI layout locally.
Verify the pulled image
skopeo inspect oci:pulled-via-skopeo:v1
{
"Digest": "sha256:3a5fa5cc98430f9e28254c14be3d021ca36a7ae4dd315604084df6a64f9d3c0d",
"Created": "2026-04-25T08:02:13Z",
"Architecture": "arm64",
"Os": "linux",
"Layers": [
"sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a",
"sha256:39cbd0bb3e72032b38fa9d83d3d3b715c3030281006bd76946a2f1a18de3f229"
],
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
]
}
Extract and run the curl binary from the pulled image:
# Extract the curl layer PULLED_MANIFEST="pulled-via-skopeo/blobs/$(jq -r '.manifests[0].digest' pulled-via-skopeo/index.json | tr ':' '/')" CURL_DIGEST=$(jq -r '.layers[1].digest' "$PULLED_MANIFEST") tar -xzf "pulled-via-skopeo/blobs/$(echo $CURL_DIGEST | tr ':' '/')" -C /tmp/verify-rootfs /tmp/verify-rootfs/usr/local/bin/curl --version | head -1
curl 8.19.0 (aarch64-pc-linux-gnu) libcurl/8.19.0 OpenSSL/3.6.1 zlib/1.3.2 brotli/1.2.0 zstd/1.5.7
The full circle: we built an image by hand (Part 1), pushed it to nginx with raw curl, and skopeo — a real OCI tool — pulled it back and confirmed every blob is intact. Our nginx server speaks enough of the Distribution Spec to fool a production-grade container tool.
Cleanup
docker rm -f oci-nginx oci-lab docker network rm oci-net
Recap
| What We Did | HTTP Method | Endpoint | OCI Spec Concept |
|---|---|---|---|
| Check API | GET | /v2/ | Version handshake |
| Push base layer | PUT | /v2/ubuntu-curl/blobs/sha256:6edbc812... | Blob upload |
| Push curl layer | PUT | /v2/ubuntu-curl/blobs/sha256:4a927cfe... | Blob upload |
| Push config | PUT | /v2/ubuntu-curl/blobs/sha256:11288e28... | Blob upload |
| Push manifest | PUT | /v2/ubuntu-curl/manifests/v1 | Manifest upload (by tag) |
| Pull manifest | GET | /v2/ubuntu-curl/manifests/v1 | Manifest download |
| Pull config | GET | /v2/ubuntu-curl/blobs/sha256:11288e28... | Blob download |
| Pull layers | GET | /v2/ubuntu-curl/blobs/sha256:... | Blob download |
| Verify integrity | sha256sum | — | Content addressability |
The big takeaway
A container registry is a content-addressable file server. The OCI Distribution Spec is an HTTP API with two core operations:
- PUT blob by digest → stores content at its hash address
- GET blob by digest → retrieves content by its hash address
The manifest ties everything together by listing digests. Tags (v1, latest) are human-readable pointers to manifest digests.
Deep Dive: The OCI Distribution Spec in Detail
We've pushed and pulled with raw HTTP. Now let's understand the full spec — every endpoint, every flow, and the design decisions behind them.
The Complete API — All Endpoints
The OCI Distribution Spec defines a small, focused HTTP API. Here is every endpoint:
| Method | Endpoint | Purpose | We Used It? |
|---|---|---|---|
GET | /v2/ | API version check | ✓ |
HEAD | /v2/<name>/blobs/<digest> | Check if blob exists | ✗ |
GET | /v2/<name>/blobs/<digest> | Download blob | ✓ |
POST | /v2/<name>/blobs/uploads/ | Start a blob upload (get upload URL) | ✗ (used PUT directly) |
PATCH | /v2/<name>/blobs/uploads/<uuid> | Upload blob chunk | ✗ |
PUT | /v2/<name>/blobs/uploads/<uuid>?digest=<digest> | Complete blob upload | ✗ (used PUT directly) |
DELETE | /v2/<name>/blobs/<digest> | Delete blob | ✗ |
HEAD | /v2/<name>/manifests/<reference> | Check if manifest exists | ✗ |
GET | /v2/<name>/manifests/<reference> | Download manifest | ✓ |
PUT | /v2/<name>/manifests/<reference> | Upload manifest | ✓ |
DELETE | /v2/<name>/manifests/<reference> | Delete manifest | ✗ |
GET | /v2/<name>/tags/list | List tags | ✓ (via static JSON) |
GET | /v2/_catalog | List repositories | ✗ |
We only used 5 of 13 endpoints. Our nginx shortcut (PUT directly to the final URL) skipped the upload session flow — let's understand why real registries need it.
Blob Upload — The Two-Step Dance
In the real spec, uploading a blob is a two-step process:
Step 1: POST /v2/ubuntu-curl/blobs/uploads/ → 202 Accepted → Location: /v2/ubuntu-curl/blobs/uploads/a1b2c3d4-uuid Step 2: PUT /v2/ubuntu-curl/blobs/uploads/a1b2c3d4-uuid?digest=sha256:6edbc812... → Body: <raw blob bytes> → 201 Created → Location: /v2/ubuntu-curl/blobs/sha256:6edbc812...
Why two steps?
- Upload sessions — The registry assigns a UUID for the upload. This lets you resume interrupted uploads. If your 500 MB layer upload dies at 400 MB, you don't start over.
- Chunked uploads — Between the POST and final PUT, you can send PATCH requests with byte ranges.
- Digest verification — The digest is only provided in the final PUT. The registry hashes the entire uploaded content and compares. If they don't match, it rejects the upload.
Our nginx doesn't support this because it's a dumb file server. We PUT directly to the final blob URL — effectively a "monolithic upload" shortcut.
Monolithic Upload — The Shortcut
The spec also allows a single-step upload:
POST /v2/ubuntu-curl/blobs/uploads/?digest=sha256:6edbc812... → Body: <raw blob bytes> → 201 Created
Or even simpler (what we did):
PUT /v2/ubuntu-curl/blobs/sha256:6edbc812... → Body: <raw blob bytes> → 201 Created
This works for small blobs but breaks for large layers over unreliable networks. Production registries always support the two-step flow.
Cross-Repository Blob Mounting
One of the most powerful (and least known) features of the Distribution Spec:
POST /v2/my-app/blobs/uploads/?mount=sha256:6edbc812...&from=ubuntu-curl → 201 Created (if the registry already has this blob in ubuntu-curl) → 202 Accepted (if not, falls back to normal upload)
What this means: If you push my-app:v1 and it shares the same Ubuntu base layer as ubuntu-curl:v1, the registry doesn't need a second copy. The blob already exists — the registry just creates a reference from the new repository to the existing blob.
ubuntu-curl/blobs/sha256:6edbc812... → [blob on disk] my-app/blobs/sha256:6edbc812... → [same blob on disk] ← mount, no re-upload
This is why docker push is fast when your base layers haven't changed — the registry already has them, and the client discovers this via HEAD requests before uploading.
Content Negotiation — Accept Headers
When pulling a manifest, the client sends Accept headers to tell the registry which formats it understands:
GET /v2/ubuntu-curl/manifests/v1
Accept: application/vnd.oci.image.manifest.v1+json,
application/vnd.docker.distribution.manifest.v2+json,
application/vnd.oci.image.index.v1+json,
application/vnd.docker.distribution.manifest.list.v2+json
The registry picks the best match and returns it with the correct Content-Type. This is how a single tag can serve both Docker and OCI clients — the registry negotiates the format.
This is also why our nginx needed a dedicated manifests location block. Without the correct Content-Type, skopeo couldn't determine the manifest format and rejected it with unsupported schema version.
The /v2/ Endpoint — Not Just a Health Check
Every Distribution Spec interaction starts with GET /v2/. It looks trivial:
GET /v2/ → 200 OK → Docker-Distribution-API-Version: registry/2.0
But it serves three purposes:
- Version detection — The
Docker-Distribution-API-Versionheader tells clients this is a v2 registry, not the legacy v1 format. - Authentication trigger — If the registry requires auth, it returns
401 Unauthorizedwith aWWW-Authenticateheader that tells the client where to get a token. - Reachability check — Before starting a multi-step push, clients verify the registry is up.
Authentication — The Token Dance
Real registries (Docker Hub, ECR, GCR, GHCR) use a token-based auth flow:
1. Client: GET /v2/
Registry: 401 + WWW-Authenticate header
2. Client: GET https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/ubuntu:pull
Auth server: 200 + {"token": "eyJhbG..."}
3. Client: GET /v2/library/ubuntu/manifests/22.04
Authorization: Bearer eyJhbG...
Registry: 200 + manifest
The three parties:
- Client (skopeo, docker, crane)
- Registry (stores blobs and manifests)
- Auth server (issues tokens — can be the same server or separate)
Our nginx has no auth — it accepts any request. This is fine for a lab but obviously not for production.
Digest Verification — Trust Nothing
The spec mandates that clients verify digests after every download:
GET /v2/ubuntu-curl/blobs/sha256:6edbc812... → 200 OK → Docker-Content-Digest: sha256:6edbc812... → Content-Length: 27606543 → Body: <bytes> Client MUST: 1. Check Content-Length matches expected size 2. Hash the body with sha256 3. Verify hash matches sha256:6edbc812... 4. Reject if any check fails
The Docker-Content-Digest header is a hint from the registry, but clients should always compute their own hash. This is why we ran sha256sum after every pull in our demo — it's what real clients do internally.
Garbage Collection — Cleaning Up
When you delete a tag or manifest, the blobs it references aren't immediately deleted. They might be shared with other manifests. Registries use garbage collection to clean up:
1. Walk all manifests → collect all referenced blob digests 2. Walk all blobs on disk 3. Delete blobs not referenced by any manifest
This is a stop-the-world operation in most registries (Docker Registry runs it as registry garbage-collect). Some newer registries (Harbor, Zot) do online GC.
In our nginx "registry," there's no GC. If you delete a manifest, the blobs stay forever. A real registry would need a metadata layer to track references.
Pagination — Large Repositories
The tags/list and _catalog endpoints support pagination:
GET /v2/ubuntu-curl/tags/list?n=100
→ 200 OK
→ Link: </v2/ubuntu-curl/tags/list?last=v100&n=100>; rel="next"
→ {"name": "ubuntu-curl", "tags": ["v1", "v2", ..., "v100"]}
GET /v2/ubuntu-curl/tags/list?last=v100&n=100
→ 200 OK
→ {"name": "ubuntu-curl", "tags": ["v101", "v102", ...]}
The Link header with rel="next" tells the client there are more results. This is important for repositories with thousands of tags (like library/ubuntu on Docker Hub).
Our static _list.json file doesn't support pagination — but it doesn't need to with one tag.
Registry Conformance — What Makes a Registry "Real"
The OCI Distribution Spec includes a conformance test suite. A registry must pass these tests to be OCI-compliant:
| Test Category | What it verifies |
|---|---|
| Pull | GET manifest by tag and digest, GET blob by digest |
| Push | POST+PUT blob upload, PUT manifest |
| Content Discovery | GET tags/list, GET _catalog |
| Content Management | DELETE manifest, DELETE blob |
Our nginx passes Pull and a partial Push (monolithic only). It fails Content Discovery (static file vs dynamic) and Content Management (no delete tracking).
A minimal conformant registry needs: blob storage, manifest storage, tag→digest mapping, and the upload session flow. Everything beyond that (auth, GC, mirroring, replication) is implementation detail.
Up Next
In Part 3, we'll take the image we built and explore the OCI Runtime Spec — how to turn a filesystem bundle into a running container using Linux namespaces, overlayfs, and a config.json.
Every HTTP request, response code, digest, and byte count in this post was captured from an actual run inside Docker Desktop for Mac on April 25, 2026. The nginx server used stock nginx:1.29.8 with a single WebDAV config file.