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 onlyskopeo,tar,jq,sha256sum,gzip, andwget. 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
| Tool | Version Used | Purpose |
|---|---|---|
| skopeo | 1.4.1 | Pull OCI images from a registry |
| jq | 1.6 | Read and write JSON |
| tar | GNU tar | Extract and create filesystem layers |
| gzip | GNU gzip | Compress layers |
| sha256sum | coreutils 8.32 | Compute content-addressable digests |
| wget | — | Download the static curl binary |
| file | — | Verify 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 inblobs/sha256/is this hash.size— 424 bytes. The exact size of the manifest blob.annotations— The tag22.04lives 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:
| Field | Points To | In Our Image |
|---|---|---|
config | Image Config (JSON) — OS, arch, env, cmd, layer history | sha256:8bdde1d7... (2,069 bytes) |
layers[0] | Filesystem layer (tar+gzip) — the actual files | sha256:6edbc812... (27,606,543 bytes ≈ 26.3 MB) |
mediaType | Tells consumers how to interpret each blob | application/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:
| Field | Value | Meaning |
|---|---|---|
architecture | arm64 | Target CPU architecture |
os | linux | Target operating system |
config.Cmd | ["/bin/bash"] | Default command when the container starts |
config.Env | ["PATH=..."] | Environment variables baked into the image |
rootfs.type | layers | Always layers — the filesystem is built from stacked diffs |
rootfs.diff_ids | ["sha256:caa9956c..."] | Uncompressed sha256 of each layer, in order |
history | Array of 5 entries | Build steps; 4 are empty_layer: true (metadata only), 1 actually added files |
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:
| Digest | Where It's Used | Computed From |
|---|---|---|
| Compressed digest | Manifest → layers[].digest | The .tar.gz file |
| DiffID | Config → 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
| Artifact | Media Type | Our Digest | Size |
|---|---|---|---|
| Image Index | — | index.json (file) | — |
| Manifest | application/vnd.oci.image.manifest.v1+json | sha256:6b615569... | 675 B |
| Config | application/vnd.oci.image.config.v1+json | sha256:6d8405fa... | 2,585 B |
| Layer 1 (base) | application/vnd.oci.image.layer.v1.tar+gzip | sha256:6edbc812... | 27,606,543 B |
| Layer 2 (curl) | application/vnd.oci.image.layer.v1.tar+gzip | sha256: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:
| Field | Purpose | Why it matters |
|---|---|---|
mediaType | What kind of thing this is | Clients know how to parse/handle it |
digest | Content-addressable hash | Guarantees integrity — if the content changes, the digest changes |
size | Byte count of the referenced blob | Clients 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:
- 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. - 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 everyubuntu:22.04image worldwide. - 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": [...]
}
| Field | Purpose |
|---|---|
architecture + os | Platform this image was built for |
config.Env | Environment variables to set |
config.Cmd | Default command to run |
config.Entrypoint | Fixed prefix for the command (not used in ours) |
config.ExposedPorts | Documentation hint for networking (not enforced) |
config.WorkingDir | The cwd for the process |
rootfs.diff_ids | Uncompressed digests of each layer, in order |
history | Human-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 Type | What it is |
|---|---|
application/vnd.oci.image.index.v1+json | Image Index (multi-arch list) |
application/vnd.oci.image.manifest.v1+json | Image Manifest |
application/vnd.oci.image.config.v1+json | Image Config |
application/vnd.oci.image.layer.v1.tar+gzip | Compressed filesystem layer |
application/vnd.oci.image.layer.v1.tar | Uncompressed filesystem layer |
application/vnd.oci.image.layer.v1.tar+zstd | Zstandard-compressed layer |
application/vnd.oci.image.layer.nondistributable.v1.tar+gzip | Layer 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:
- Add/Modify — File exists in the tar → add or overwrite it in the filesystem
- Delete — A file named
.wh.<name>(whiteout) → delete<name>from lower layers - Opaque whiteout — A file named
.wh..wh..opqin 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-layoutmust exist and contain{"imageLayoutVersion": "1.0.0"}index.jsonis the entry point — start here, follow descriptors- All blobs live under
blobs/<algorithm>/— typicallyblobs/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 type | application/vnd.docker.distribution.manifest.v2+json | application/vnd.oci.image.manifest.v1+json |
| Index/list media type | application/vnd.docker.distribution.manifest.list.v2+json | application/vnd.oci.image.index.v1+json |
| Config media type | application/vnd.docker.container.image.v1+json | application/vnd.oci.image.config.v1+json |
| Layer media type | application/vnd.docker.image.rootfs.diff.tar.gzip | application/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.