# Docker manifest unknown when pulling in CI

> Fix docker manifest unknown in GitHub Actions: the repository exists and the reference does not, for one of four reasons the log hides.

Source: https://latchkey.dev/learn/docker/docker-manifest-unknown  
Updated: 2026-09-20

Docker manifest unknown means the registry found the repository and could not find the thing you named inside it, which is a narrower failure than it looks and never an authentication problem. The reference is wrong, the tag is gone, your platform is missing from the index, or the client and the registry disagreed about formats.

## What this error means

A pull fails in about a second with nothing downloaded, and other tags from the same repository pull normally in the same job. The message is assembled from two programs and the halves look redundant. The daemon contributes a sentence naming your reference, and the registry contributes an error code whose rendering repeats its own default message, so you get "manifest unknown" twice at the end of a line that already told you what was missing. On a runner using the containerd image store the daemon sentence is different and much shorter. No reproduction is recorded for this page; the two shapes below are assembled from the source that formats each half.

```Both shapes as their source files format them, not a recorded run
Error response from daemon: manifest for acme/api:relese-1 not found: manifest unknown: manifest unknown
--- same tag, same registry, a runner on the containerd image store
Error response from daemon: failed to resolve reference "ghcr.io/acme/api:relese-1": ghcr.io/acme/api:relese-1: not found
```

## Common causes

### The tag was never pushed, or was pushed under a different name

A typo in a tag, a build matrix that produced `1.4.2-linux-amd64` while the pull asks for `1.4.2-amd64`, or a publish job that was skipped on this branch all leave the registry with nothing at that reference. The tell is that the repository answers other tags happily, which rules out the name and the credentials in one go.

### Your platform is not in the manifest list

A multi-architecture tag is an index pointing at one manifest per platform. If the index exists but has no entry for the platform the runner asked for, resolution fails at the second step. This is the common one on arm64 runners pulling an image whose publish job only builds amd64, and it looks exactly like a missing tag from the outside.

### The client and the registry disagreed about formats

The registry returns the same code when the stored manifest is an OCI index and the request did not say it accepted one. The message in that case says so, but only if something in your pipeline prints the registry message rather than just the daemon sentence. Tools that predate OCI indexes are the usual source.

### The tag was deleted or moved after you resolved it

Retention policies, a cleanup job, or a tag repointed by a later publish can all remove the manifest a pinned reference expects. A digest reference makes this loud instead of quiet: if the digest is gone, the content is genuinely gone, which is a clearer signal than a tag that now points somewhere else.

## How to fix it

### Fail the publish job loudly rather than letting the pull discover it

Most of these are a missing push discovered one job too late. Verify the tag from the job that produced it, before anything downstream depends on it, so the error names the publish rather than the consumer.

```.github/workflows/publish.yml
- name: Confirm what we just pushed is resolvable
  run: |
    for tag in 1.4.2 1.4.2-amd64 1.4.2-arm64; do
      docker buildx imagetools inspect "ghcr.io/acme/api:$tag" >/dev/null \
        || { echo "::error::ghcr.io/acme/api:$tag did not resolve"; exit 1; }
    done
```

### Name the platform when you pull on a mixed fleet

1. Add the platform to the pull so a missing entry fails with a platform in the message.
2. Check the index first if the job runs on both amd64 and arm64 runners.
3. Build the missing architecture rather than pinning consumers to the one that exists.

```Terminal
docker pull --platform linux/arm64 ghcr.io/acme/api:1.4.2
docker buildx imagetools inspect ghcr.io/acme/api:1.4.2 | grep -i platform
```

### Pin to a digest so a moved tag cannot go quiet

A digest is content addressed, so it either resolves to exactly the bytes you tested against or it is gone. That turns a silently repointed tag into an explicit failure, which is the behavior you want in a deploy pipeline even though it costs you an update step.

```Terminal
docker pull ghcr.io/acme/api@sha256:9f2c1b7d4a0e6f3c8b25d9107ae4f6b2c1d8e3a7
```

### Widen the Accept header on the tool that cannot read indexes

If the registry message mentions the accept header, the fix belongs to whichever client sent the request, not to your image. Upgrade the scanner or mirroring tool, or publish a Docker format manifest list alongside the OCI index for that consumer. Changing your build to stop producing indexes is the heavier option and usually the wrong one.

```Terminal
curl -sS -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json" \
  -H "Authorization: Bearer $TOKEN" \
  https://ghcr.io/v2/acme/api/manifests/1.4.2 | head -c 200
```

## How to prevent it

- Verify every tag in the job that pushed it, not in the job that consumes it.
- Publish every platform your runners use, or pin consumers to the platform you publish.
- Reference deploy images by digest and let a resolver update the digest on purpose.
- Keep retention policies away from tags a pipeline still resolves.

## Why the line ends with the same two words twice

The registry answers a missing manifest with HTTP 404 and the code `MANIFEST_UNKNOWN`, whose default message is the string "manifest unknown". The distribution client renders an error as its code lowercased, with underscores turned into spaces, followed by the message. Code and message are the same phrase here, so the rendering doubles it.

The daemon then wraps that. Its not-found type switches on the code it received and writes a different sentence for each: `MANIFEST_UNKNOWN` becomes "manifest for <reference> not found", `NAME_UNKNOWN` becomes "repository <name> not found", and `DENIED` becomes the older "pull access denied" wording. The wrap joins its sentence to the cause with a colon, and the CLI puts "Error response from daemon: " on the front of the lot.

That matters for searching. The full line you paste into a search box was assembled by three components at runtime and exists as a literal in none of them, so a code search for it finds nothing and proves nothing. Search a distinctive fragment of one half instead.

| Piece of the line | Which program wrote it |
| --- | --- |
| `Error response from daemon: ` | The Docker API client, which prefixes every daemon error |
| `manifest for <ref> not found` | The daemon, chosen by the registry code it received |
| `manifest unknown: ` | The distribution client, rendering the code `MANIFEST_UNKNOWN` |
| `manifest unknown` | The registry, as the message field of its JSON error |

> Read on 2026-09-20 in the daemon error wrapper, the distribution error rendering, and the distribution error registry. The containerd path replaces the middle two with a single "not found".

## The cause that is not a missing tag

A registry can hold your manifest and still answer `MANIFEST_UNKNOWN`, because the same handler uses that code for a format disagreement. When a client asks for a tag without listing OCI types in its `Accept` header, and the stored manifest is an OCI index, the registry returns `MANIFEST_UNKNOWN` with the message "OCI index found, but accept header does not support OCI indexes". There is a sibling message for a plain OCI manifest.

So a tag that pulls fine with one tool and reports manifest unknown with another is usually this, not a deletion. Older scanners, mirroring tools and language-specific registry clients are the ones that hit it, because they send a short Accept header written before OCI indexes were common.

## Check the reference before you change anything

Two commands settle almost every instance. Listing the tags tells you whether the name you used is in the repository at all, which catches the typo and the push that never ran. Inspecting the tag as a raw document tells you what format it is stored in and which platforms it covers.

Run both from the job, not locally, because a tag your laptop cached months ago will resolve from that cache and tell you nothing about what the registry holds today.

```.github/workflows/ci.yml
- name: Prove the reference exists before pulling it
  run: |
    docker buildx imagetools inspect ghcr.io/acme/api:1.4.2 || true
    docker buildx imagetools inspect --raw ghcr.io/acme/api:1.4.2 \
      | head -c 400
```

## Why no recorded run backs this page

The point of this page is which half of the message came from where, and a single recorded run can only show one half at a time. A run on the classic image store would produce the long doubled wording; a run on the containerd store would produce the short one; neither would show the reader the pairing that makes the message legible. Publishing one and describing the other would be the same shortcut this rebuild exists to undo.

The pairing is checkable without a run, because the switch that picks the daemon sentence and the rendering that doubles the registry phrase are both in source you can open. That is what this page cites, and it is stronger evidence for a claim about assembly than a log of one instance would be.

## FAQ

### Does manifest unknown mean I am not logged in?

No. The registry uses a different code for that: `UNAUTHORIZED` at 401 when it cannot identify you, and `DENIED` at 403 when it can and refuses anyway. `MANIFEST_UNKNOWN` is a 404 and means the lookup succeeded and found nothing at that reference. If you were unauthenticated against a private repository you would see the access denied wording instead.

### Why does the message repeat manifest unknown twice?

Because the error code and its default message are the same phrase, and the client prints both. The code `MANIFEST_UNKNOWN` renders as "manifest unknown" and the message field also reads "manifest unknown". It carries no extra meaning, and a registry that sets a custom message will show something more useful in the second position.

### Can a tag exist and still report manifest unknown?

Yes, in two ways. The tag can be an index with no entry for your platform, so the second resolution step finds nothing. Or the stored manifest can be an OCI index while the client did not say it accepts one, which the registry answers with the same code and a message naming the accept header. Both look identical unless something prints the registry message.

### Should I search for the whole error line?

No, and it will mislead you if you do. The line is joined at runtime from a daemon sentence, a client rendering and a registry message, so it appears as a literal in none of the three programs. Search a short distinctive fragment of one half, such as the daemon wording, and check what you find against the source rather than against a count of results.

## References

- [moby: the daemon wrapper that picks a sentence per registry error code](https://github.com/moby/moby/blob/master/daemon/internal/distribution/errors.go)
- [distribution: MANIFEST_UNKNOWN in the error code registry](https://github.com/distribution/distribution/blob/main/registry/api/errcode/register.go)
- [distribution: the manifest handler and its accept header messages](https://github.com/distribution/distribution/blob/main/registry/handlers/manifests.go)
- [Docker docs: docker buildx imagetools inspect](https://docs.docker.com/reference/cli/docker/buildx/imagetools/inspect/)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
