Skip to content
Latchkey LogoLatchkey home

Docker containerd image store not enabled in CI

Docker containerd image store not enabled is the real condition behind a buildx refusal that never mentions containerd, because buildx decides by reading one field in docker info and reports only the consequence. The same refusal fires for a single-platform build that carries attestations, which is why it can appear the day you turn provenance on.

Diagram of the driver-type check buildx makes before a build, and the two branches
Buildx looks for a driver-type of io.containerd.snapshotter.v1 in docker info. With it, the docker exporter is quietly swapped for the OCI one and the load works.

What this error means

A docker buildx build --load fails immediately, before any build step runs, with a sentence about the docker exporter and manifest lists. Nothing in it names containerd, the image store, or a setting you could change. The same command with --push instead of --load succeeds, which sends people looking at the registry. A second wording exists for builds that write a tar file. The block below sets both out as the two programs that emit them format them, because no run is recorded for this page.

Both wordings as buildx formats them, not a recorded run
--- buildx refused this before the build started
ERROR: docker exporter does not currently support exporting manifest lists
--- same build with --output type=docker,dest=image.tar
ERROR: docker exporter does not support exporting manifest lists, use the oci exporter instead

The check buildx makes, and what it does with the answer

Before a build starts, buildx asks the daemon for its info and looks through the driver status entries for a pair whose first element is driver-type and whose second is io.containerd.snapshotter.v1. That single test is what it calls the OCI importer feature.

If the feature is present and you did not ask for a file on disk, buildx silently rewrites your docker export into an oci export, because the OCI importer handles multi-platform images. If it is absent, and you asked for more than one platform or for any attestation, it refuses, and the wording depends on whether you asked for a file. Neither wording mentions the test it just failed.

Note the second half of that condition. More than one platform is the famous trigger, but attestations trigger it too, on a perfectly ordinary single-platform build. A workflow that adds provenance for supply chain reasons can start failing with a message about manifest lists it never asked for.

What the job asked forClassic image storecontainerd image store
One platform, no attestations, --loadLoads normallyLoads normally
Two or more platforms, --loadRefused before the buildLoads, through the OCI importer
One platform with provenance or SBOM, --loadRefused before the buildLoads, through the OCI importer
Any of the above with --output type=docker,dest=Refused, with the wording naming the oci exporterRefused, because a file output is not importable

Common causes

The daemon is using the classic image store

The classic store holds one platform per image, so it has no way to keep a manifest list locally. Buildx knows this from the driver status and refuses rather than building something it cannot import. Most Linux CI runners ship this way unless the daemon configuration says otherwise.

Attestations were turned on and nobody changed the platform list

Provenance and SBOM attestations are described in a format the docker exporter cannot carry, so buildx treats a build with attestations the same way it treats a multi-platform build. In our experience this is the confusing one, because the message talks about manifest lists and the workflow only ever built one platform.

The build writes a tar file rather than loading

When an output destination is set, buildx cannot substitute the OCI importer, because there is nothing to import into. That path refuses on both image stores and says so in different words, naming the oci exporter as the alternative. Enabling the containerd store does not help here.

The setting was applied but the daemon was not restarted

The image store is chosen when the daemon starts. Writing the feature flag into the daemon configuration file changes nothing until the daemon reads it again, and a job that edits the file and immediately builds will see the old store. The check in the step above will say so plainly.

How to fix it

Turn on the containerd snapshotter for the job

  1. Write the feature flag into the daemon configuration file on the runner.
  2. Restart the daemon and wait for it to come back before the next step.
  3. Assert the driver status shows the containerd snapshotter, so a silent failure to restart fails loudly instead.
.github/workflows/ci.yml
- name: Enable the containerd image store
  run: |
    echo '{ "features": { "containerd-snapshotter": true } }' \
      | sudo tee /etc/docker/daemon.json
    sudo systemctl restart docker
    timeout 60 bash -c 'until docker info >/dev/null 2>&1; do sleep 2; done'
    docker info --format "{{json .DriverStatus}}" | grep -q snapshotter \
      || { echo "::error::containerd image store did not come up"; exit 1; }

Or stop asking for a local multi-platform image

Most pipelines load an image only to run tests against it, and tests run on one architecture at a time. Load the platform the job will actually use and push the full set separately. This is the cheaper fix and it survives a runner image change.

Terminal
docker buildx build --platform linux/amd64 --load -t acme/api:test .
docker buildx build --platform linux/amd64,linux/arm64 --push -t acme/api:1.4.2 .

Turn attestations off for the load and keep them for the push

If the build is single-platform and the failure appeared when provenance did, split the concerns. The load exists to run tests and does not need attestations; the push does. Turning them off on the load is not giving anything up, because nothing consumes an attestation from the local image store.

Terminal
docker buildx build --load --provenance=false --sbom=false -t acme/api:test .

For a tar output, ask for the OCI layout the message names

The second wording tells you the fix outright. The oci exporter writes a layout that holds a manifest list, and tools that consume image archives increasingly read it. Change the output type rather than enabling the image store, because the store is not what blocked this one.

Terminal
docker buildx build --platform linux/amd64,linux/arm64 \
  --output type=oci,dest=image.tar -t acme/api:1.4.2 .

Confirm the store before you change anything

The check buildx makes is one you can make yourself in a step, and it answers the question directly rather than by inference. Reading the storage driver name alone is not enough, because the name changes between versions and the field buildx actually reads is the driver status list.

Run this on the runner, not locally. A developer machine with Docker Desktop very often has the containerd store on while the CI runner does not, which is exactly why the build works in one place and not the other.

.github/workflows/ci.yml
- name: Report the image store the way buildx reads it
  run: |
    docker info --format "{{json .DriverStatus}}"
    docker info --format "{{.Driver}}"

Why no recorded run backs this page

A recorded run of this failure would be a recording of our runner configuration. The error is produced by a client-side branch on a value the daemon reports about itself, so a log proves only which setting that particular daemon had, and a reader whose runner is configured differently learns nothing from it. The interesting content is the branch, and the branch is readable in the source without spending a runner minute.

There is also nothing to repair here. The build never started, no registry was contacted, and the condition is a daemon configuration decision made before the job existed. A recorded run would show a failure that a retry, a bigger runner and a warm cache all leave exactly where it is.

How to prevent it

  • Assert the driver status in the job rather than assuming the runner image kept it.
  • Keep the platform list on the load step down to the one the tests run on.
  • Decide attestations per output, not once for the whole workflow.
  • Wait for the daemon to answer after a restart, so a slow start does not read as the old store.

Frequently asked questions

Why does --push work when --load fails?
Because a push sends the manifest list to a registry, which has always been able to hold one. The refusal is about keeping a manifest list in the local image store, which the classic store cannot do. Nothing about the registry, the credentials or the network is involved in this failure, which is why changing any of them has no effect.
How does buildx decide the store is containerd?
It reads the daemon info and looks for a driver status entry whose key is driver-type and whose value is io.containerd.snapshotter.v1. That is the whole test. You can run the same query in a step with a JSON format string, which is more reliable than reading the storage driver name, since that name has changed between versions.
Does enabling the containerd image store lose my existing images?
The two stores keep separate content, so images built under one are not visible under the other. On an ephemeral CI runner that does not matter, because nothing is there to lose. On a long-lived self-hosted runner it does, and a warm cache of base images will appear to vanish until they are pulled again.
Why did this start failing when I added provenance?
Because buildx treats any attestation the same way it treats a second platform. Attestations need a format the docker exporter cannot write, so the same branch refuses the build, with a message about manifest lists that does not mention attestations at all. Turning provenance off on the load step restores the previous behavior.

Related guides

References

Latchkey keeps the Docker layer cache on the runner, so a rebuild starts where the last one stopped. Start free → 30-day trial · No credit card