# OCI images explained: manifests, layers, media types and artifacts

> What an OCI image is made of, how layers stack, how Helm charts and Flux artifacts reuse the format, and how tools fetch only the pieces they need.

Published: 2026-11-05
Author: k8wiz Team
URL: https://k8wiz.com/articles/oci-images-explained-manifests-layers-media-types-and-artifacts

A container image looks like one thing when you type `docker pull`, but a registry stores it as a handful of JSON documents and tarballs, each named by the SHA-256 of its bytes. Once you know those pieces, a lot of behavior stops being mysterious: why a multi-arch tag gives different bytes on a Mac and on a server, why deleting a file in a Dockerfile doesn't shrink the image, how a Helm chart fits in a container registry, and how Flux pulls one layer out of an artifact and ignores the rest.

This guide walks through the format from the bottom up, then shows how real tools query it. The [interactive graph of an image in a registry](/graphs/oci-image-index-manifest-config-layers) and the [graph of OCI artifacts](/graphs/oci-artifacts-helm-flux-image-volumes) draw the same objects, if you'd rather click through them.

## Three specifications

The Open Container Initiative maintains three specs, and they split the work cleanly.

- The **image spec** defines the documents: image index, image manifest, image config, layers and the descriptors that link them.
- The **distribution spec** defines the HTTP API a registry serves: push and pull manifests and blobs, list tags, find referrers.
- The **runtime spec** defines how a runtime such as runc starts a container from an unpacked filesystem and a `config.json`. Registries never see it.

Version 1.1 of the image and distribution specs, released in February 2024, made registries officially general-purpose. It added the `artifactType` and `subject` fields to manifests and the referrers API, which is how signatures and SBOMs get attached to an image without changing its digest.

## Everything is content-addressed

A registry is a key-value store where the key is a digest such as `sha256:4f2c8e…`, the hash of the exact bytes stored. Change one byte and the key changes. That gives you two properties for free. Content can't be altered without changing its name, and identical content is stored once no matter how many images use it.

Objects point at each other with **descriptors**. A descriptor is a small JSON object that says what the target is, its digest and its size:

```json
{
  "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
  "digest": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4",
  "size": 29150641
}
```

The client checks both the size and the digest after download, so a registry or proxy that corrupts or swaps a blob gets caught. Descriptors can also carry `annotations`, `platform` (in an index) and `artifactType`.

## The four kinds of object

A typical multi-platform image is a small tree. A tag points at an index, the index points at one manifest per platform, and each manifest points at a config and an ordered list of layers.

### Image index

The index, media type `application/vnd.oci.image.index.v1+json`, lists manifests and says which platform each is for. Docker's older equivalent is the manifest list, `application/vnd.docker.distribution.manifest.list.v2+json`.

```json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:7d1c…",
      "size": 1024,
      "platform": { "architecture": "amd64", "os": "linux" }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:e83a…",
      "size": 1024,
      "platform": { "architecture": "arm64", "os": "linux", "variant": "v8" }
    }
  ]
}
```

A client pulling `shop:1.4.2` on an arm64 node reads this, picks the entry matching its platform and fetches only that manifest. The amd64 layers are never downloaded. That's also why `docker inspect` on two machines can show different image IDs for the same tag.

### Image manifest

The manifest, `application/vnd.oci.image.manifest.v1+json`, describes one image for one platform: a config and the layers, in order.

```json
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:b5d2…",
    "size": 2913
  },
  "layers": [
    { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:a3ed…", "size": 29150641 },
    { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:1f8b…", "size": 8312447 },
    { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:c09e…", "size": 14223104 }
  ],
  "annotations": {
    "org.opencontainers.image.source": "https://github.com/acme/shop",
    "org.opencontainers.image.revision": "4f2c8e1"
  }
}
```

The digest of this document is what you pin with `image: ghcr.io/acme/shop@sha256:7d1c…`. Pinning the index digest works too and keeps the image multi-platform.

### Image config

The config, `application/vnd.oci.image.config.v1+json`, holds everything the runtime needs that isn't files: platform, environment, entrypoint, working directory, user, exposed ports, labels, plus the build history and the list of layer `diff_ids`.

```json
{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "User": "10001",
    "Env": ["PATH=/usr/local/bin:/usr/bin:/bin"],
    "Entrypoint": ["/usr/local/bin/shop"],
    "WorkingDir": "/app"
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": ["sha256:9a1b…", "sha256:44d0…", "sha256:7e3f…"]
  },
  "history": [
    { "created_by": "debian:bookworm-slim base" },
    { "created_by": "RUN apt-get install -y ca-certificates tzdata" },
    { "created_by": "COPY shop /usr/local/bin/shop" },
    { "created_by": "ENTRYPOINT [\"/usr/local/bin/shop\"]", "empty_layer": true }
  ]
}
```

In Docker's classic image store, the config's digest is the image ID. With the containerd image store, Docker shows the manifest or index digest instead. Instructions like `ENV` or `ENTRYPOINT` only change the config, and the history marks them `empty_layer: true`.

### Layers

A layer is a tar archive of filesystem changes, usually compressed. The image spec defines three media types:

| Media type | Content |
| --- | --- |
| `application/vnd.oci.image.layer.v1.tar` | Uncompressed tar |
| `application/vnd.oci.image.layer.v1.tar+gzip` | Gzip, the default almost everywhere |
| `application/vnd.oci.image.layer.v1.tar+zstd` | Zstandard, faster to decompress and usually smaller |

Docker's own format uses `application/vnd.docker.image.rootfs.diff.tar.gzip`. The "non-distributable" layer types from the 1.0 spec, meant for Windows base layers, are deprecated in 1.1 and new tools shouldn't write them.

Zstd layers need containerd 1.5 or later. If old nodes or scanners still pull your images, check before you switch, because a client that doesn't know a media type refuses the whole image.

## How layers stack

Each layer records what changed compared with the layers below it. The runtime unpacks them in order, and the result is the container's root filesystem. On Linux, containerd's overlayfs snapshotter keeps each layer as a separate directory and stacks them as overlay `lowerdir`s, with an empty writable `upperdir` on top for the running container.

Adding or changing a file is easy, because the layer just contains the new file. Deleting needs a marker, because a tar can't express "this file is gone". The spec uses **whiteout** files:

- A file named `.wh.config.yaml` in a layer means `config.yaml` from a lower layer is deleted.
- A file named `.wh..wh..opq` in a directory makes the directory **opaque**: everything below it from lower layers is hidden, and only this layer's contents show.

That explains the classic surprise. A Dockerfile that downloads a 500 MB archive in one `RUN` and deletes it in the next produces an image that still carries the 500 MB, in the lower layer. The upper layer only adds a whiteout. Download and delete in the same `RUN`, or use a multi-stage build, and the archive never lands in a layer.

### Two digests per layer

Every layer has two digests, and mixing them up confuses many people.

- The **layer digest** in the manifest is the hash of the compressed blob. It's what the registry stores and what you download.
- The **diff ID** in the config's `rootfs.diff_ids` is the hash of the uncompressed tar. It's what the runtime checks after decompressing.

The same filesystem change compressed with gzip and with zstd gives two layer digests but one diff ID. Runtimes go one step further and compute a **chain ID** for each position in the stack: the first layer's chain ID is its diff ID, and each later one is the SHA-256 of the previous chain ID, a space and the layer's diff ID. A snapshot with a given chain ID is the same filesystem no matter which image it came from, so two images built on the same base share the unpacked base on disk.

### Layer order and caching

Builders cache by layer. If a step and everything above it are unchanged, the layer is reused, and a registry skips the upload because it already has the digest. Put rarely changing steps (base image, OS packages, dependency install) first and fast-changing ones (copying your source) last. Changing a line near the top invalidates every layer above it.

## Multi-platform images and attestations

`docker buildx build --platform linux/amd64,linux/arm64` pushes one manifest per platform and an index over them. With provenance or SBOM generation on, which recent buildx does by default for provenance, the index gets extra entries that confuse people the first time:

```json
{
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "digest": "sha256:0c4d…",
  "platform": { "architecture": "unknown", "os": "unknown" },
  "annotations": {
    "vnd.docker.reference.type": "attestation-manifest",
    "vnd.docker.reference.digest": "sha256:7d1c…"
  }
}
```

That's an **attestation manifest** for the amd64 image. Its layers have the media type `application/vnd.in-toto+json`, and each layer's `in-toto.io/predicate-type` annotation says whether it's an SBOM (`https://spdx.dev/Document`) or SLSA provenance. The `unknown/unknown` platform keeps runtimes from ever trying to run it.

## Not just containers: OCI artifacts

Nothing in the distribution API cares what the blobs contain. A manifest whose config and layers have other media types is an **artifact**, and registries store it like any image. Clients tell artifacts apart by `artifactType` or, in older tools, by the config's media type.

| Content | Config media type | Layer media types |
| --- | --- | --- |
| Container image | `application/vnd.oci.image.config.v1+json` | `application/vnd.oci.image.layer.v1.tar+gzip` |
| Docker image | `application/vnd.docker.container.image.v1+json` | `application/vnd.docker.image.rootfs.diff.tar.gzip` |
| Helm chart | `application/vnd.cncf.helm.config.v1+json` | `application/vnd.cncf.helm.chart.content.v1.tar+gzip`, plus `application/vnd.cncf.helm.chart.provenance.v1.prov` if signed |
| Flux artifact | `application/vnd.cncf.flux.config.v1+json` | `application/vnd.cncf.flux.content.v1.tar+gzip` |
| Cosign signature (classic) | `application/vnd.oci.image.config.v1+json` | `application/vnd.dev.cosign.simplesigning.v1+json` |
| Generic ORAS file | `application/vnd.oci.empty.v1+json` | Whatever you declare, such as `application/vnd.acme.report.v1+json` |

### Helm charts

`helm push shop-0.9.0.tgz oci://ghcr.io/acme/charts` stores the chart as `ghcr.io/acme/charts/shop:0.9.0`. The config blob is `Chart.yaml` as JSON, and the single content layer is the packaged `.tgz`. Helm replaces a `+` in the version with `_` in the tag, because tags can't contain `+`.

`helm install shop oci://ghcr.io/acme/charts/shop --version 0.9.0` fetches the manifest, finds the layer with the chart media type and downloads only that. There's no `index.yaml`, so listing versions means listing tags.

### Flux artifacts

`flux push artifact oci://ghcr.io/acme/manifests/shop:4f2c8e1 --path=./deploy --source=https://github.com/acme/shop --revision=main@sha1:4f2c8e1` packs a directory of YAML into one layer and records the Git source and revision as annotations. An OCIRepository pulls it, and a Kustomization applies it, with no Git access from the cluster.

### Empty configs and artifactType

An artifact with nothing to put in its config uses the empty descriptor defined in the 1.1 spec. It always has the same digest, because its content is always `{}`:

```json
{
  "mediaType": "application/vnd.oci.empty.v1+json",
  "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
  "size": 2,
  "data": "e30="
}
```

The manifest's `artifactType` then says what it is. ORAS writes this layout by default:

```bash
oras push ghcr.io/acme/reports/scan:4f2c8e1 \
  --artifact-type application/vnd.acme.scan-report.v1 \
  report.json:application/vnd.acme.report.v1+json
```

Each file becomes a layer with an `org.opencontainers.image.title` annotation holding its file name, which is how `oras pull` knows what to call it on disk.

### Signatures, SBOMs and referrers

A signature can't live inside the image it signs, because adding it would change the image's digest. The 1.1 answer is the `subject` field. The signature is a separate manifest whose `subject` descriptor points at the image. The registry indexes it, and anyone can ask for everything that refers to a digest:

```text
GET /v2/acme/shop/referrers/sha256:7d1c…?artifactType=application/vnd.dev.sigstore.bundle.v0.3+json
```

The answer is an image index listing the matching manifests. On a registry without the referrers API, clients fall back to a tag named after the digest, `sha256-7d1c…`, which holds that index. Classic cosign used a similar convention, the tag `sha256-7d1c….sig`, before referrers existed. Recent cosign versions can store a Sigstore bundle and attach it through referrers instead.

### Image volumes in Kubernetes

Since Kubernetes 1.36, image volumes (KEP-4639) are stable. A Pod can mount the filesystem of any image as a read-only volume, so data, models or config ship through the registry you already have, and the application image stays small:

```yaml
apiVersion: v1
kind: Pod
metadata:
  name: inference
spec:
  containers:
    - name: server
      image: ghcr.io/acme/inference-server:3.2.0
      volumeMounts:
        - name: model
          mountPath: /models/sentiment
          readOnly: true
  volumes:
    - name: model
      image:
        reference: ghcr.io/acme/models/sentiment:2026-10
        pullPolicy: IfNotPresent
```

The volume image is an ordinary image, usually built `FROM scratch` with a single `COPY` of the files. The container runtime pulls it like any other image and mounts its unpacked layers. You need a runtime that supports it: containerd 2.1 or later, or CRI-O 1.31 or later. CRI-O can also mount OCI artifacts that aren't images. With containerd, build the volume as an image.

## How tools query a registry

Every tool above speaks the same small HTTP API. You can drive it with curl. Here's the full flow for the public `alpine` image on Docker Hub.

1. Get an anonymous pull token:

```bash
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/alpine:pull" | jq -r .token)
```

2. Fetch the tag. The `Accept` header lists the formats you understand, and the registry answers with one of them. The `Docker-Content-Digest` response header gives the digest:

```bash
curl -s -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json" \
  https://registry-1.docker.io/v2/library/alpine/manifests/3.20 | jq .
```

3. Pick a platform from the index and fetch that manifest by digest, with the same request and `/manifests/sha256:…` at the end.

4. Fetch a blob, the config or a layer. Docker Hub answers with a redirect to its CDN, so follow it:

```bash
curl -sL -H "Authorization: Bearer $TOKEN" \
  https://registry-1.docker.io/v2/library/alpine/blobs/sha256:<config digest> | jq .config
```

A `HEAD` request on a manifest returns the digest without the body. That's why `imagePullPolicy: Always` is cheap. The runtime resolves the tag on every Pod start, but it only downloads blobs it doesn't already have.

### The same with the usual tools

These all wrap the calls above.

```bash
crane manifest alpine:3.20                          # the index, raw
crane manifest --platform linux/arm64 alpine:3.20   # one platform's manifest
crane config alpine:3.20 | jq .config.Entrypoint    # just the config
crane blob alpine@sha256:<layer digest> | tar -tz    # list the files in one layer
crane digest alpine:3.20                            # what to pin
```

```bash
skopeo inspect --raw docker://docker.io/library/alpine:3.20
docker buildx imagetools inspect --raw ghcr.io/acme/shop:1.4.2
oras manifest fetch ghcr.io/acme/charts/shop:0.9.0
oras discover ghcr.io/acme/shop:1.4.2                # signatures, SBOMs and other referrers
```

### Choosing one layer

A client that wants a specific piece reads the manifest and picks a layer by media type, then downloads only that blob.

- Helm takes the layer whose media type is `application/vnd.cncf.helm.chart.content.v1.tar+gzip`.
- Flux's OCIRepository has a `layerSelector` for it. With `operation: extract`, the default, source-controller unpacks the layer as the artifact. With `operation: copy` it keeps the tarball as is, which is what a HelmRelease's `chartRef` needs:

```yaml
spec:
  url: oci://ghcr.io/stefanprodan/charts/podinfo
  ref:
    semver: ">=6.7.0"
  layerSelector:
    mediaType: application/vnd.cncf.helm.chart.content.v1.tar+gzip
    operation: copy
```

- ORAS can filter by file name, because each layer carries an `org.opencontainers.image.title` annotation.

### Reading part of a layer

Some setups go further and fetch only part of a blob with HTTP `Range` requests, so a container starts before its image is fully downloaded. eStargz, used by the stargz snapshotter, and zstd:chunked, used by Podman and CRI-O, append a table of contents to each layer that maps files to byte ranges. AWS's SOCI keeps the original layers and stores the index as a separate artifact that refers to the image. In all three, the runtime fetches the files a process opens first and pulls the rest in the background.

## What containerd does on a pull

When the kubelet asks containerd for `ghcr.io/acme/shop:1.4.2`, these are the steps you'd see in the containerd debug logs:

1. Resolve the tag with a `HEAD` or `GET` on `/manifests/1.4.2`, sending an `Accept` header for every format it supports.
2. If it gets an index, pick the manifest for the node's platform.
3. Fetch the manifest and the config, then every layer the content store doesn't already have, several in parallel. Each blob is checked against its digest and size.
4. Decompress each layer, check it against its `diff_id`, and apply it to a snapshot on top of the previous layer's snapshot, keyed by chain ID.
5. Record the image name and digest, which is what `crictl images --digests` lists.

Blobs live under `/var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/` and the unpacked layers under the snapshotter's directory. Garbage collection removes blobs and snapshots that no image references anymore.

## Practical checklist

- Pin production images by digest, `image@sha256:…`. Tags can be moved, digests can't.
- Pin the index digest, not one platform's manifest, if your cluster mixes architectures.
- Check what you're about to pull with `crane manifest` or `docker buildx imagetools inspect` before blaming the runtime for an `exec format error`. It's usually an image without your platform.
- Keep secrets and big temporary files out of every layer, not just the last one. Whiteouts hide files, they don't remove them.
- Order Dockerfile steps from least to most frequently changed, so the cache and the registry can reuse lower layers.
- Before switching layers to zstd, make sure every runtime, scanner and proxy that pulls the image supports it.
- Use the referrers API, through cosign, notation or `oras discover`, to find signatures and SBOMs instead of guessing tag names.
