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 — just curl and 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 pointThe manifest URL is the entry point
Tags live in annotationsTags live in the URL path
One index.json per image directoryRegistry 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.json is 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 DirectiveWhy
dav_methods PUT DELETEEnables file creation via HTTP PUT — the core of "pushing"
create_full_put_path onAuto-creates /v2/ubuntu-curl/blobs/ directory tree on first PUT
client_max_body_size 200mLayer blobs can be huge; default 1 MB limit would reject them
root /data/registryPUT to /v2/foo/blobs/sha256:abc stores file at /data/registry/v2/foo/blobs/sha256:abc
Separate manifests blockManifests must be served with Content-Type: application/vnd.oci.image.manifest.v1+json so clients can parse them
alias for tags/listServes a static JSON file so skopeo inspect and tag listing work
Docker-Distribution-API-VersionHeader 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:

MethodEndpointPurpose
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:

BlobTypeSizeDigest
Base layertar+gzip27,606,543 bytessha256:6edbc812af48...
Curl layertar+gzip4,638,209 bytessha256:4a927cfe2d12...
ConfigJSON2,585 bytessha256:11288e28f77c...
ManifestJSON675 bytessha256: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:

  1. curl read the 27 MB layer blob from the local OCI layout
  2. Sent an HTTP PUT to the URL /v2/ubuntu-curl/blobs/sha256:6edbc812...
  3. nginx created the directory tree /data/registry/v2/ubuntu-curl/blobs/ (thanks to create_full_put_path)
  4. Wrote the blob to disk as a file named sha256:6edbc812...
  5. 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:

FeatureOur nginxReal Registry
Blob uploadSingle PUTTwo-step: POST (get upload URL) → PATCH/PUT (stream data)
Chunked uploads✓ (resume interrupted uploads)
Content validation✓ (verify digest matches content on upload)
Tag listingStatic JSON fileGET /v2/<name>/tags/list (dynamic)
AuthenticationNoneBearer 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:

  1. Store the manifest by its sha256 digest (just like our nginx)
  2. Create a tag→digest mapping — record that v1 points to sha256:3a5fa5cc...
  3. Update the tag list — append v1 to the list of known tags for ubuntu-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 DidHTTP MethodEndpointOCI Spec Concept
Check APIGET/v2/Version handshake
Push base layerPUT/v2/ubuntu-curl/blobs/sha256:6edbc812...Blob upload
Push curl layerPUT/v2/ubuntu-curl/blobs/sha256:4a927cfe...Blob upload
Push configPUT/v2/ubuntu-curl/blobs/sha256:11288e28...Blob upload
Push manifestPUT/v2/ubuntu-curl/manifests/v1Manifest upload (by tag)
Pull manifestGET/v2/ubuntu-curl/manifests/v1Manifest download
Pull configGET/v2/ubuntu-curl/blobs/sha256:11288e28...Blob download
Pull layersGET/v2/ubuntu-curl/blobs/sha256:...Blob download
Verify integritysha256sumContent 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:

  1. PUT blob by digest → stores content at its hash address
  2. 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:

MethodEndpointPurposeWe 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/listList tags✓ (via static JSON)
GET/v2/_catalogList 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?

  1. 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.
  2. Chunked uploads — Between the POST and final PUT, you can send PATCH requests with byte ranges.
  3. 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:

  1. Version detection — The Docker-Distribution-API-Version header tells clients this is a v2 registry, not the legacy v1 format.
  2. Authentication trigger — If the registry requires auth, it returns 401 Unauthorized with a WWW-Authenticate header that tells the client where to get a token.
  3. 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 CategoryWhat it verifies
PullGET manifest by tag and digest, GET blob by digest
PushPOST+PUT blob upload, PUT manifest
Content DiscoveryGET tags/list, GET _catalog
Content ManagementDELETE 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.