Series: Understanding OCI from the Ground Up (Part 1 of 5)

In this post we pull an Ubuntu base image, tear it open, add a static curl binary, and assemble a brand-new OCI image — using only skopeo, tar, jq, sha256sum, gzip, and wget. No Docker build. No Podman build. No Dockerfile. Every digest and byte count in this post is real, captured from an actual run.

What is the OCI Image Spec?

The OCI Image Specification defines a vendor-neutral format for container images. It answers one question: what does a container image look like on disk?

An OCI image is a directory of content-addressable blobs tied together by JSON metadata:

index.json  →  manifest  →  config   (JSON — OS, arch, env, cmd, layer history)
                          →  layer(s) (tar+gzip — filesystem diffs)

By the end of this post you'll have built one from scratch and understood every byte.


Prerequisites

We work inside a Linux environment (a Docker container, VM, or bare metal). Install these tools:

apt-get update -qq
apt-get install -y skopeo jq wget file
ToolVersion UsedPurpose
skopeo1.4.1Pull OCI images from a registry
jq1.6Read and write JSON
tarGNU tarExtract and create filesystem layers
gzipGNU gzipCompress layers
sha256sumcoreutils 8.32Compute content-addressable digests
wgetDownload the static curl binary
fileVerify binary type

Step 1 — Pull the Base Image as an OCI Layout

Skopeo copies images between transports. We copy from a Docker registry (docker://) to a local OCI directory layout (oci:).

mkdir -p /work && cd /work
skopeo copy docker://ubuntu:22.04 oci:ubuntu-base:22.04
Getting image source signatures
Copying blob sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a
Copying config sha256:8bdde1d721460fc0f8516943144befc29636bc3692712525cde2b873d03d965d
Writing manifest to image destination
Storing signatures

Notice the output — skopeo downloaded one blob (the filesystem layer) and one config, then wrote the manifest. These are the three pillars of the OCI Image Spec.

What landed on disk

find ubuntu-base -type f | sort
ubuntu-base/blobs/sha256/0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8
ubuntu-base/blobs/sha256/6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a
ubuntu-base/blobs/sha256/8bdde1d721460fc0f8516943144befc29636bc3692712525cde2b873d03d965d
ubuntu-base/index.json
ubuntu-base/oci-layout

Five files. That's an entire container image. Let's examine each one.


Step 2 — Anatomy of the OCI Image Layout

2a. oci-layout — The Version Marker

jq . ubuntu-base/oci-layout
{
  "imageLayoutVersion": "1.0.0"
}

Every OCI image layout starts with this file. It tells tools which version of the spec to expect. Simple.

2b. index.json — The Entry Point

jq . ubuntu-base/index.json
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8",
      "size": 424,
      "annotations": {
        "org.opencontainers.image.ref.name": "22.04"
      }
    }
  ]
}

Key points:

  • manifests[] — An array of image manifests. Multi-architecture images list one manifest per platform here.
  • digest — The sha256 of the manifest file. This is a content address — the filename in blobs/sha256/ is this hash.
  • size — 424 bytes. The exact size of the manifest blob.
  • annotations — The tag 22.04 lives here, not in a filename.

The digest sha256:0124b538... tells us the manifest lives at:

ubuntu-base/blobs/sha256/0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8

2c. Image Manifest — The Blueprint

MANIFEST_PATH="ubuntu-base/blobs/sha256/0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8"
jq . "$MANIFEST_PATH"
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 2069,
    "digest": "sha256:8bdde1d721460fc0f8516943144befc29636bc3692712525cde2b873d03d965d"
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 27606543,
      "digest": "sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a"
    }
  ]
}

The manifest binds three things:

FieldPoints ToIn Our Image
configImage Config (JSON) — OS, arch, env, cmd, layer historysha256:8bdde1d7... (2,069 bytes)
layers[0]Filesystem layer (tar+gzip) — the actual filessha256:6edbc812... (27,606,543 bytes ≈ 26.3 MB)
mediaTypeTells consumers how to interpret each blobapplication/vnd.oci.image.*

2d. Image Config — Container Metadata

CONFIG_PATH="ubuntu-base/blobs/sha256/8bdde1d721460fc0f8516943144befc29636bc3692712525cde2b873d03d965d"
jq . "$CONFIG_PATH"
{
  "architecture": "arm64",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/bash"
    ],
    "Image": "sha256:33154be9758df4f3843211046c6ee6bcd58a81047e3c30f5be6b2e0aa7e5c98e",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": {
      "org.opencontainers.image.version": "22.04"
    }
  },
  "container": "90f0e91ef7df3a5e1bc3ce6bffcdaf5edc33b13c50e6de4a06a9a25d8a46b0c3",
  "container_config": {
    "Hostname": "90f0e91ef7df",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"/bin/bash\"]"
    ],
    "Image": "sha256:33154be9758df4f3843211046c6ee6bcd58a81047e3c30f5be6b2e0aa7e5c98e",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": {
      "org.opencontainers.image.version": "22.04"
    }
  },
  "created": "2026-04-10T09:49:13.564928244Z",
  "docker_version": "26.1.3",
  "history": [
    {
      "created": "2026-04-10T09:49:11.206548809Z",
      "created_by": "/bin/sh -c #(nop)  ARG RELEASE",
      "empty_layer": true
    },
    {
      "created": "2026-04-10T09:49:11.270560246Z",
      "created_by": "/bin/sh -c #(nop)  ARG LAUNCHPAD_BUILD_ARCH",
      "empty_layer": true
    },
    {
      "created": "2026-04-10T09:49:11.313061468Z",
      "created_by": "/bin/sh -c #(nop)  LABEL org.opencontainers.image.version=22.04",
      "empty_layer": true
    },
    {
      "created": "2026-04-10T09:49:13.257870411Z",
      "created_by": "/bin/sh -c #(nop) ADD file:94ca084e2c34d90b4443d18fa6a7d983767fa1575d4bd2c06f6e31adfea270da in / "
    },
    {
      "created": "2026-04-10T09:49:13.564928244Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/bash\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:caa9956c50e9facf65021733bc7890b61ce94076388533506c80c8ae1b603db4"
    ]
  },
  "variant": "v8"
}

Important fields to understand:

FieldValueMeaning
architecturearm64Target CPU architecture
oslinuxTarget operating system
config.Cmd["/bin/bash"]Default command when the container starts
config.Env["PATH=..."]Environment variables baked into the image
rootfs.typelayersAlways layers — the filesystem is built from stacked diffs
rootfs.diff_ids["sha256:caa9956c..."]Uncompressed sha256 of each layer, in order
historyArray of 5 entriesBuild steps; 4 are empty_layer: true (metadata only), 1 actually added files
Key distinction: The manifest stores the compressed layer digest (sha256:6edbc812...). The config stores the uncompressed layer digest (sha256:caa9956c...). These are different hashes of the same data — compressed vs uncompressed.

2e. The Layer Blob — Actual Filesystem

LAYER_PATH="ubuntu-base/blobs/sha256/6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a"
tar -tzf "$LAYER_PATH" | head -25
bin
boot/
dev/
etc/
etc/.pwd.lock
etc/adduser.conf
etc/alternatives/
etc/alternatives/README
etc/alternatives/awk
etc/alternatives/nawk
etc/alternatives/pager
etc/alternatives/rmt
etc/alternatives/which
etc/apt/
etc/apt/apt.conf.d/
etc/apt/apt.conf.d/01-vendor-ubuntu
etc/apt/apt.conf.d/01autoremove
etc/apt/apt.conf.d/70debconf
etc/apt/apt.conf.d/docker-autoremove-suggests
etc/apt/apt.conf.d/docker-clean
etc/apt/apt.conf.d/docker-disable-periodic-update
etc/apt/apt.conf.d/docker-gzip-indexes
etc/apt/apt.conf.d/docker-no-languages
etc/apt/auth.conf.d/
etc/apt/keyrings/

A layer is a gzipped tar archive of filesystem changes. That's it. No magic. The entire Ubuntu 22.04 base filesystem — /bin, /etc, /usr, everything — is packed in this single 26.3 MB tar.gz file.

2f. Content Addressability — Proving It

Every blob is named by its own sha256 hash. Let's verify:

sha256sum "$LAYER_PATH"
6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a  ubuntu-base/blobs/sha256/6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a

The hash is the filename. This gives us:

  • Integrity — if a single bit changes, the hash won't match → corruption detected
  • Deduplication — identical layers across different images share the same blob
  • Immutability — you can't modify a blob without changing its address

Relationship Diagram

index.json
  │
  └─► manifests[0].digest
        = sha256:0124b538...  (424 bytes)
        │
        ▼
    Manifest  (blobs/sha256/0124b538...)
        │
        ├─► config.digest
        │     = sha256:8bdde1d7...  (2,069 bytes)
        │     │
        │     ▼
        │   Config JSON  (blobs/sha256/8bdde1d7...)
        │     ├── architecture: "arm64"
        │     ├── os: "linux"
        │     ├── config.Cmd: ["/bin/bash"]
        │     └── rootfs.diff_ids:
        │           └── sha256:caa9956c...  (uncompressed)
        │
        └─► layers[0].digest
              = sha256:6edbc812...  (27,606,543 bytes)
              │
              ▼
            Layer tar+gzip  (blobs/sha256/6edbc812...)
              └── bin/, boot/, dev/, etc/, usr/, var/ ...

Step 3 — Extract the Root Filesystem

To work with the image, extract all layers in order into a rootfs directory:

mkdir -p rootfs
tar -xzf "ubuntu-base/blobs/sha256/6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a" -C rootfs
ls rootfs/
bin  boot  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
cat rootfs/etc/os-release | head -4
PRETTY_NAME="Ubuntu 22.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="22.04"
VERSION="22.04.5 LTS (Jammy Jellyfish)"

We now have a full Ubuntu filesystem extracted from a single OCI layer.


Step 4 — Add curl (Static Binary)

Instead of running apt-get install inside a chroot (which requires mounting /proc, /sys, /dev and sudo), we download a statically compiled curl binary — a single executable with all libraries linked in.

CURL_URL="https://github.com/stunnel/static-curl/releases/download/8.19.0/curl-linux-aarch64-musl-8.19.0.tar.xz"
wget -q -O /tmp/curl.tar.xz "$CURL_URL"
tar -xf /tmp/curl.tar.xz -C /tmp/
mkdir -p rootfs/usr/local/bin
cp /tmp/curl rootfs/usr/local/bin/curl
chmod +x rootfs/usr/local/bin/curl

Verify the binary

ls -la rootfs/usr/local/bin/curl
-rwxr-xr-x 1 root root 9671880 Mar 11 13:36 rootfs/usr/local/bin/curl
file rootfs/usr/local/bin/curl
rootfs/usr/local/bin/curl: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), static-pie linked, stripped
ldd rootfs/usr/local/bin/curl
statically linked

9.6 MB, statically linked, no dependencies. It will run on any Linux aarch64 system regardless of what libraries are installed.

rootfs/usr/local/bin/curl --version | head -2
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 c-ares/1.34.6 libidn2/2.3.8 libpsl/0.21.5 libssh2/1.11.1 nghttp2/1.68.0 ngtcp2/1.21.0 nghttp3/1.15.0
Release-Date: 2026-03-11

Step 5 — Create a New OCI Layer

A layer is a tar archive of the filesystem diff — only the files that changed. Since we added one file, our diff is simple.

5a. Capture the diff

mkdir -p diff/usr/local/bin
cp rootfs/usr/local/bin/curl diff/usr/local/bin/curl

find diff -type f
diff/usr/local/bin/curl

One file. That's our entire layer.

5b. Create the tar archive

tar -cf new-layer.tar -C diff .
gzip -nf new-layer.tar
ls -la new-layer.tar.gz
-rw-r--r-- 1 root root 4638210 Apr 25 07:17 new-layer.tar.gz

9.6 MB binary compressed down to 4.4 MB.

5c. Compute the digests

OCI uses two different digests per layer:

DigestWhere It's UsedComputed From
Compressed digestManifest → layers[].digestThe .tar.gz file
DiffIDConfig → rootfs.diff_ids[]The uncompressed .tar
# Compressed digest (for the manifest)
sha256sum new-layer.tar.gz
34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969  new-layer.tar.gz

Compressed digest: sha256:34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969
Compressed size: 4638210 bytes

# DiffID — uncompressed digest (for the config)
gzip -dc new-layer.tar.gz | sha256sum
8e230161e3a449bd40573b10a57c2ae918eee503a0b6e7eeb95ecb5faeca51d1  -

DiffID: sha256:8e230161e3a449bd40573b10a57c2ae918eee503a0b6e7eeb95ecb5faeca51d1


Step 6 — Assemble the New OCI Image

We now construct a complete OCI image layout using jq and cp.

6a. Create the directory and copy base blobs

mkdir -p ubuntu-curl/blobs/sha256
echo '{"imageLayoutVersion":"1.0.0"}' > ubuntu-curl/oci-layout
cp ubuntu-base/blobs/sha256/* ubuntu-curl/blobs/sha256/

6b. Add the new layer blob

cp new-layer.tar.gz ubuntu-curl/blobs/sha256/34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969

6c. Create the new Image Config

We take the base config, append our layer's diffID, and change the default command to curl:

jq \
  --arg diffid "sha256:8e230161e3a449bd40573b10a57c2ae918eee503a0b6e7eeb95ecb5faeca51d1" \
  '
    .rootfs.diff_ids += [$diffid] |
    .config.Cmd = ["/usr/local/bin/curl", "--help"] |
    .created = (now | todate)
  ' "$CONFIG_PATH" > new-config.json

The key sections of the new config:

{
  "architecture": "arm64",
  "os": "linux",
  "created": "2026-04-25T07:17:51Z",
  "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:8e230161e3a449bd40573b10a57c2ae918eee503a0b6e7eeb95ecb5faeca51d1"
    ]
  }
}

Two diff_ids now — the base Ubuntu layer and our curl layer.

Store it by its digest:

sha256sum new-config.json
6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d  new-config.json
cp new-config.json ubuntu-curl/blobs/sha256/6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d

New config digest: sha256:6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d (2,585 bytes)

6d. Create the new Manifest

Add our layer and point to the new config:

jq \
  --arg cfg_digest "sha256:6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d" \
  --argjson cfg_size 2585 \
  --arg layer_digest "sha256:34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969" \
  --argjson layer_size 4638210 \
  '
    .config.digest = $cfg_digest |
    .config.size = $cfg_size |
    .layers += [{
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": $layer_digest,
      "size": $layer_size
    }]
  ' "$MANIFEST_PATH" > new-manifest.json

The result:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 2585,
    "digest": "sha256:6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d"
  },
  "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:34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969",
      "size": 4638210
    }
  ]
}

Two layers now. Store by digest:

sha256sum new-manifest.json
6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4  new-manifest.json
cp new-manifest.json ubuntu-curl/blobs/sha256/6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4

New manifest digest: sha256:6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4 (675 bytes)

6e. Create index.json

cat > ubuntu-curl/index.json << 'EOF'
{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4",
      "size": 675,
      "annotations": {
        "org.opencontainers.image.ref.name": "v1"
      }
    }
  ]
}
EOF

6f. Final OCI image layout

find ubuntu-curl -type f | sort
ubuntu-curl/blobs/sha256/0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8   ← base manifest (unused but harmless)
ubuntu-curl/blobs/sha256/34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969   ← NEW: curl layer (4,638,210 bytes)
ubuntu-curl/blobs/sha256/6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4   ← NEW: our manifest (675 bytes)
ubuntu-curl/blobs/sha256/6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d   ← NEW: our config (2,585 bytes)
ubuntu-curl/blobs/sha256/6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a   ← base Ubuntu layer (27,606,543 bytes)
ubuntu-curl/blobs/sha256/8bdde1d721460fc0f8516943144befc29636bc3692712525cde2b873d03d965d   ← base config (unused but harmless)
ubuntu-curl/index.json
ubuntu-curl/oci-layout

Step 7 — Verify with Skopeo

skopeo inspect oci:ubuntu-curl:v1
{
    "Digest": "sha256:6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4",
    "RepoTags": [],
    "Created": "2026-04-25T07:17:51Z",
    "DockerVersion": "26.1.3",
    "Labels": {
        "org.opencontainers.image.version": "22.04"
    },
    "Architecture": "arm64",
    "Os": "linux",
    "Layers": [
        "sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a",
        "sha256:34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969"
    ],
    "Env": [
        "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ]
}

Skopeo confirms:

  • Two layers — Ubuntu base + our curl layer
  • Architecture: arm64, OS: linux
  • Digest matches our manifest hash

Content-addressability proof

Every blob filename matches its sha256:

MATCH: 0124b5388c7c05576c0cedeab121a7c590b0ec16b4238e6b997ad9d57ccdefd8
MATCH: 34e3daf7fed461e9aa2b651733c7df7f75f4d96d38a90081e6fe53173ba71969
MATCH: 6b6155697fad9df7cf606c79e19d12c933d6fe49a594a8ca088f0e9f1999ddb4
MATCH: 6d8405fa348ef7f04d621be8971801e9d6faad8b60ea04ab6d805746adaa425d
MATCH: 6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a
MATCH: 8bdde1d721460fc0f8516943144befc29636bc3692712525cde2b873d03d965d

Every single blob checks out. The image is valid OCI.


Recap — What We Built

ubuntu-curl:v1 — OCI Image
│
├── index.json
│     └── manifest digest: sha256:6b615569...  (675 bytes)
│
├── Manifest (sha256:6b615569...)
│     ├── config: sha256:6d8405fa...  (2,585 bytes)
│     ├── layer 1: sha256:6edbc812...  (27,606,543 bytes)  ← Ubuntu 22.04 base
│     └── layer 2: sha256:34e3daf7...  (4,638,210 bytes)   ← static curl binary
│
├── Config (sha256:6d8405fa...)
│     ├── arch: arm64, os: linux
│     ├── Cmd: ["/usr/local/bin/curl", "--help"]
│     └── diff_ids:
│           ├── sha256:caa9956c...  (Ubuntu layer, uncompressed)
│           └── sha256:8e230161...  (curl layer, uncompressed)
│
└── Layer Stacking
      ┌──────────────────────────────────┐
      │  Layer 2: /usr/local/bin/curl    │  ← 4.4 MB (compressed)
      ├──────────────────────────────────┤
      │  Layer 1: Ubuntu 22.04 base      │  ← 26.3 MB (compressed)
      └──────────────────────────────────┘

OCI Image Spec — The Complete Picture

ArtifactMedia TypeOur DigestSize
Image Indexindex.json (file)
Manifestapplication/vnd.oci.image.manifest.v1+jsonsha256:6b615569...675 B
Configapplication/vnd.oci.image.config.v1+jsonsha256:6d8405fa...2,585 B
Layer 1 (base)application/vnd.oci.image.layer.v1.tar+gzipsha256:6edbc812...27,606,543 B
Layer 2 (curl)application/vnd.oci.image.layer.v1.tar+gzipsha256:34e3daf7...4,638,210 B

Deep Dive: The OCI Image Spec in Detail

We've built an image. Now let's understand the spec itself — every object type, every design decision, and why it works the way it does.

The Descriptor — The Universal Reference

Every reference in the OCI Image Spec uses the same structure: a descriptor. When the manifest points to a config, it uses a descriptor. When it points to a layer, it uses a descriptor. When index.json points to a manifest, descriptor.

{
  "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
  "digest": "sha256:6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a",
  "size": 27606543
} 

A descriptor always has exactly three fields:

FieldPurposeWhy it matters
mediaTypeWhat kind of thing this isClients know how to parse/handle it
digestContent-addressable hashGuarantees integrity — if the content changes, the digest changes
sizeByte count of the referenced blobClients can pre-allocate, show progress bars, and detect truncation

Why size is mandatory: A digest alone can't tell you if you received a truncated download. If you expected 27,606,543 bytes but only got 27,000,000, the digest would differ — but you'd have to hash the entire download to discover the error. Size lets you fail fast.

Content Addressability — The Design Foundation

The entire OCI Image Spec is built on one idea: name things by their content hash.

File: 6edbc812af48552062d74659cf0a08b413dbfdbacdd5aac73329d889d9b3b44a
      ↑ This IS the filename. It's also the sha256 of the file's contents.

This gives you three properties for free:

  1. Integrity — If someone hands you blob sha256:6edbc812..., you hash it. If the hash matches, the content is correct. No signatures needed for data integrity.
  2. Deduplication — If two images share the same Ubuntu 22.04 base layer, they reference the same digest. The blob is stored once. In our image, the base layer sha256:6edbc812... is the exact same blob that exists in every ubuntu:22.04 image worldwide.
  3. Immutability — You can't change a blob without changing its digest, which changes the manifest's digest, which changes the index. A single bit flip cascades through the entire chain. This is why image digests are used for pinning in production: ubuntu@sha256:6edbc812... will always be the same image.
Change 1 byte in a layer
  → Layer digest changes
    → Manifest changes (it references the new digest)
      → Manifest digest changes
        → Index changes (it references the new manifest digest)

The Manifest — Bill of Materials

The manifest is the heart of an image. It lists exactly what the image contains:

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": { <descriptor> },
  "layers": [ <descriptor>, <descriptor>, ... ]
}

Key rules:

  • Layer order matters. Layers are applied bottom-up. Layer 0 is the base. Layer N is applied on top. In our image: layer 0 = Ubuntu, layer 1 = curl.
  • Config is a blob, not inline JSON. It's stored in blobs/sha256/ just like layers, and the manifest references it by digest.
  • The manifest itself is content-addressed. Its digest (sha256:6b615569...) is how registries and tools identify the image.

The Image Config — Runtime Metadata

The config is the only part of the image that tells a runtime how to run the container:

{
  "architecture": "arm64",
  "os": "linux",
  "config": {
    "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "Cmd": ["/usr/local/bin/curl", "--help"]
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:caa9956c...",
      "sha256:8e230161..."
    ]
  },
  "history": [...]
}
FieldPurpose
architecture + osPlatform this image was built for
config.EnvEnvironment variables to set
config.CmdDefault command to run
config.EntrypointFixed prefix for the command (not used in ours)
config.ExposedPortsDocumentation hint for networking (not enforced)
config.WorkingDirThe cwd for the process
rootfs.diff_idsUncompressed digests of each layer, in order
historyHuman-readable build history (what created each layer)

diff_ids vs layer digests: The manifest lists compressed layer digests (what you download). The config lists uncompressed layer digests (diff_ids). Why? A compressed blob's digest depends on the gzip implementation — different tools might produce different compressed outputs from the same tar. The uncompressed digest is canonical, so diff_ids are used to verify layer integrity after extraction.

Image Index — Multi-Architecture Support

Our index.json has one manifest. But the OCI spec supports an Image Index (sometimes called a "manifest list") — a list of manifests for different platforms:

{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:aaa...",
      "size": 675,
      "platform": { "architecture": "amd64", "os": "linux" }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:bbb...",
      "size": 680,
      "platform": { "architecture": "arm64", "os": "linux" }
    }
  ]
}

When you docker pull ubuntu:22.04, Docker fetches the index first, finds the manifest matching your platform, then pulls that manifest's layers. This is why the same tag works on both x86 laptops and ARM-based Macs.

docker pull ubuntu:22.04
  ├── Fetch index → 2 manifests (amd64, arm64)
  ├── Match platform → arm64
  ├── Fetch arm64 manifest → config + layers
  └── Pull layers for arm64

Our image only has one manifest (arm64), so the index is simple. But the mechanism is identical.

Media Types — The Type System

Every blob in OCI has a media type. This is how tools know what they're looking at:

Media TypeWhat it is
application/vnd.oci.image.index.v1+jsonImage Index (multi-arch list)
application/vnd.oci.image.manifest.v1+jsonImage Manifest
application/vnd.oci.image.config.v1+jsonImage Config
application/vnd.oci.image.layer.v1.tar+gzipCompressed filesystem layer
application/vnd.oci.image.layer.v1.tarUncompressed filesystem layer
application/vnd.oci.image.layer.v1.tar+zstdZstandard-compressed layer
application/vnd.oci.image.layer.nondistributable.v1.tar+gzipLayer that shouldn't be pushed to registries (e.g., proprietary base layers)

Why media types matter for registries: When a client pushes a manifest, the registry stores the media type. When another client pulls it, the registry sends the correct Content-Type header. This is why our nginx needed a special location block for manifests in Part 2 — without the correct Content-Type, skopeo rejected the manifest.

Layers — Filesystem Diffs

A layer is a tar archive representing filesystem changes. The OCI spec defines three operations:

  1. Add/Modify — File exists in the tar → add or overwrite it in the filesystem
  2. Delete — A file named .wh.<name> (whiteout) → delete <name> from lower layers
  3. Opaque whiteout — A file named .wh..wh..opq in a directory → hide all files from lower layers in that directory
Layer 2 (curl):
  usr/
  usr/local/
  usr/local/bin/
  usr/local/bin/curl    ← ADD: new file

If we wanted to DELETE /etc/motd, the layer would contain:
  etc/
  3698
  
  etc/.wh.motd          ← WHITEOUT: delete motd from lower layers

If we wanted to REPLACE everything in /tmp/:
  tmp/
  tmp/.wh..wh..opq      ← OPAQUE: hide all lower-layer files in /tmp
  tmp/new-file.txt       ← Only this file visible in /tmp

Whiteouts are how overlayfs deletions are stored in OCI layers. When you docker commit a container, the upper layer's character device whiteouts (major:minor 0:0) are converted to .wh. files in the tar archive. We'll see this in action in Part 3.

OCI Image Layout — The On-Disk Format

When you have an OCI image as a directory (like after skopeo copy ... oci:myimage), it follows the OCI Image Layout format:

myimage/
├── oci-layout          ← version marker: {"imageLayoutVersion": "1.0.0"}
├── index.json          ← entry point: lists manifests
└── blobs/
    └── sha256/
        ├── 6b615569... ← manifest
        ├── 6d8405fa... ← config
        ├── 6edbc812... ← layer 1 (Ubuntu base)
        └── 34e3daf7... ← layer 2 (curl)

Rules of the layout:

  • oci-layout must exist and contain {"imageLayoutVersion": "1.0.0"}
  • index.json is the entry point — start here, follow descriptors
  • All blobs live under blobs/<algorithm>/ — typically blobs/sha256/
  • Blob filenames are their digest (without the sha256: prefix)
  • The layout is self-contained — no external references needed

This layout is a transport format. You can tar it, ship it, and untar it somewhere else — all the content and metadata travel together.

Docker Manifest vs OCI Manifest

You'll encounter two manifest formats in the wild:

Docker (v2s2)OCI
Manifest media typeapplication/vnd.docker.distribution.manifest.v2+jsonapplication/vnd.oci.image.manifest.v1+json
Index/list media typeapplication/vnd.docker.distribution.manifest.list.v2+jsonapplication/vnd.oci.image.index.v1+json
Config media typeapplication/vnd.docker.container.image.v1+jsonapplication/vnd.oci.image.config.v1+json
Layer media typeapplication/vnd.docker.image.rootfs.diff.tar.gzipapplication/vnd.oci.image.layer.v1.tar+gzip

The structure is nearly identical — OCI was derived from Docker's v2 format. Most registries and tools handle both. skopeo transparently converts between them. When we used skopeo copy docker://ubuntu:22.04 oci:ubuntu-base:22.04, skopeo converted from Docker format to OCI format.


Up Next

In Part 2, we'll take this image and push it to an OCI registry — exploring the Distribution Spec by watching every HTTP request that skopeo makes, and then replicating the entire push/pull flow using nothing but curl and the registry's REST API.


Every digest, byte count, and command output in this post was captured from an actual run inside an Ubuntu 22.04 container on Docker Desktop for Mac on April 25, 2026.