Skip to content
Latchkey LogoLatchkey home

Docker push manifest invalid from a registry

A docker push manifest invalid failure is the registry refusing the document that describes your image, not the layers, and the reason sits in a detail field that no Docker client prints. Fetch the response body yourself, or change the media types buildx writes, because those are the only two moves that touch the actual disagreement.

Diagram showing a registry 400, the detail field clients drop, and the exporter option
The registry answers 400 with code "MANIFEST_INVALID" and a detail naming the media type. Neither client prints the detail, which is why the log looks empty of reasons.

What this error means

Layers upload normally, sometimes all of them, and the step dies on the last request of the push. The wording depends on which client pushed. BuildKit and buildx surface the transport failure and the status code. The classic Docker daemon surfaces the registry error code instead, which renders as the word "manifest invalid" printed twice, because the code and its default message are the same phrase. Neither one prints the sentence that says which media type was refused. We have not recorded a run for this page, and the block below is assembled from the strings each component formats rather than captured from a job.

Constructed from the strings each component formats, not a recorded run
#14 exporting manifest list sha256:c3b9d1a2
#14 ERROR: failed to push acme/api:1.4.2: unexpected status from PUT request to https://registry.example.internal/v2/acme/api/manifests/1.4.2: 400 Bad Request
--- the body that came with that 400, fetched separately with curl
{"errors":[{"code":"MANIFEST_INVALID","message":"manifest invalid","detail":"unsupported manifest media type and no default available: application/vnd.oci.image.index.v1+json"}]}

The useful sentence is in the body, and the client throws it away

A registry built on the distribution project answers a manifest upload it cannot parse with HTTP 400 and the error code MANIFEST_INVALID. The code carries a default message that happens to read "manifest invalid", and the specific reason goes in a separate detail field.

Then the client drops it. In the distribution client library an error renders as its code followed by its message, and the Detail property is not part of that rendering at all, so a structured error whose detail names the exact media type prints as "manifest invalid: manifest invalid". The containerd path does not do better: its unexpected-status error stores the response body on the struct and formats only the method, the URL and the status. So the answer is on the wire and not in your log.

Where the string is formedWhat reaches the Actions log
Registry, PutManifest in the distribution handlersHTTP 400, code MANIFEST_INVALID, message "manifest invalid", detail naming the type
Registry, UnmarshalManifest in distribution"unsupported manifest media type and no default available: " and the type, as the detail
Distribution client, its error renderingThe code and the message only, so "manifest invalid: manifest invalid"
containerd remotes, its unexpected-status errorMethod, URL and status only, so "400 Bad Request" with no reason

Common causes

The registry does not map the OCI media type you pushed

The registry looks the request Content-Type up in a table of unmarshal functions, and a registry old enough to predate OCI image indexes has no entry for one. There is no fallback unless a default is registered, which is exactly what the detail sentence says when it ends "and no default available". In our experience this is the common shape behind a 400 on the last request of a push.

Attestations pulled the whole export into OCI types

Provenance and SBOM attestations cannot be described in the Docker manifest format, so an export that carries them uses OCI media types regardless of what you would otherwise have got. A workflow that turned on provenance for supply-chain reasons can start failing against a registry that was fine the week before, with no change to the Dockerfile.

A BuildKit upgrade changed the default

BuildKit pins media-type behavior to a compatibility version. On the current path as of 2026-09-20, an image export that does not set oci-mediatypes defaults to OCI media types. On the historical path that covers v0.15.0 through v0.31.x, an export without attestations defaulted to Docker media types instead. Upgrading the builder therefore changes what you push without changing your workflow.

The manifest is schema 1 and something in the chain refuses it

This is the other direction and it is usually a pull rather than a push. A base image published years ago can still be served as application/vnd.docker.distribution.manifest.v1+prettyjws, which modern clients decline. The wording overlaps with the push case enough to be confusing, and the fix has nothing to do with your exporter.

How to fix it

Write Docker media types from the image exporter

The exporter takes oci-mediatypes as a boolean option. Setting it false makes buildx write the classic manifest and manifest list formats, which is what a registry that refused the OCI index is asking for. Set it on the output rather than globally, so a second push to a modern registry is unaffected.

Terminal
docker buildx build \
  --output type=image,name=registry.example.internal/acme/api:1.4.2,oci-mediatypes=false,push=true \
  .

Turn off the attestations that force OCI

  1. Add --provenance=false and --sbom=false to the build that pushes to the strict registry.
  2. Keep them on for the registry that accepts them, if you publish to both.
  3. Treat this as a concession, not a cleanup: you are giving up supply-chain metadata to satisfy a registry.
.github/workflows/publish.yml
- uses: docker/build-push-action@v7
  with:
    push: true
    provenance: false
    sbom: false
    tags: registry.example.internal/acme/api:1.4.2
    outputs: type=image,oci-mediatypes=false

Pin the exporter behavior across a BuildKit upgrade

The compatibility-version exporter option pins the digest-affecting image assembly behavior, which includes the media-type default. Pinning it makes the upgrade that changes the default a deliberate step rather than something you find out about from a registry.

Terminal
--output type=image,name=acme/api:1.4.2,push=true,compatibility-version=20

For the schema 1 direction, replace the image

If the type in the detail ends manifest.v1+prettyjws, nothing about your exporter is involved. The image on the other end is in a format current clients removed support for. Repoint the reference at a maintained tag, or mirror the old image through a registry that converts it, and leave your push settings alone.

Terminal
docker buildx imagetools inspect --raw quay.io/coreos/etcd:v3.3.10 | head -5

Get the detail out of the registry yourself

Because no client prints it, the fastest diagnosis is to make the same request by hand and read the body. A HEAD against the tag is enough to see the negotiation, and a PUT replay is rarely necessary once you know which types the registry advertises it will accept.

Do this from the job that failed rather than from your laptop, because the token, the registry host and the proxy are all part of what you are testing.

.github/workflows/publish.yml
- name: Ask the registry what it says
  run: |
    TOKEN=$(echo "${{ secrets.REGISTRY_TOKEN }}")
    curl -sS -i -X HEAD \
      -H "Authorization: Bearer $TOKEN" \
      -H "Accept: application/vnd.oci.image.index.v1+json" \
      "https://registry.example.internal/v2/acme/api/manifests/1.4.2"

What "unsupported media type" does and does not point at

Two strings in this area are about the old schema 1 format, not about OCI, and reading them backwards sends you to the wrong fix. The distribution sentence "unsupported manifest media type and no default available" appears with application/vnd.docker.distribution.manifest.v1+prettyjws in m3db/m3#4396, where a modern daemon refuses to pull a 2018-era image. The go-containerregistry library has its own "unsupported MediaType" error, and in that library it is raised only for schema 1 manifests, with a link to the issue explaining that the project will not support them.

So when you find either phrase while searching, check the direction before you act. Pulling an ancient image is a schema 1 problem and the fix is a newer image. Pushing a fresh buildx image to a registry that will not take it is an OCI problem, and the fix is on the exporter.

The builder version decides the default, so pin it

BuildKit pins the behavior that affects an image digest, media types included, to a compatibility version you can set as an exporter option. Three values exist at the time of writing and only the newest one defaults an unset oci-mediatypes to OCI, which is why a builder upgrade is a plausible answer to "nothing changed and it broke".

Set it on the push that goes to the strict registry. You are not freezing the builder, only the part of its output that the registry has an opinion about.

BuildKit compatibility versionDefault when oci-mediatypes is unset
30, the current path as of 2026-09-20OCI media types
20, the v0.15.0 through v0.31.x pathDocker media types, unless the export carries attestations
10, the v0.13.0 and v0.14.0 pathDiffers from 20 on git source file modes and on zstd, not on media types

Why no recorded run backs this page

This failure needs a registry that refuses a format, and every registry the reproduction harness can reach accepts both OCI and Docker manifests. Recording a run would mean standing up a deliberately old registry build, pushing to it, and then presenting the result as though it were a registry you might actually use. That would record our configuration and teach nothing about yours.

The parts that are checkable without a run are checked: the code that returns the status, the function that writes the detail, and the two error types that decline to print it. What a run would add is a screenshot of a log we can already tell you is missing the important line.

How to prevent it

  • Pin compatibility-version on any push that goes to a registry you do not control.
  • Push a throwaway tag from a new builder version before you move the real pipeline to it.
  • Keep one curl step in the workflow that prints the registry response body on failure.
  • Record which of your registries accept OCI indexes, because the log will not tell you later.

Frequently asked questions

Why does the log say manifest invalid twice?
Because the error code and its default message are the same words. The distribution client renders an error as its code, lowercased with underscores turned into spaces, followed by the message. MANIFEST_INVALID becomes "manifest invalid" and its default message is also "manifest invalid", so you get both halves. The part that differs between cases is the detail, which that rendering leaves out.
Does oci-mediatypes=false lose anything?
It gives up the formats that attestations need, so an export carrying provenance or an SBOM cannot use it. It also writes the older manifest and manifest list types, which every registry understands, so nothing else about the image changes. Treat it as a compatibility setting for one destination rather than a default you apply everywhere.
How do I see the detail field the client hides?
Repeat the request with curl from the same job and read the response body. The registry returns a JSON object with an errors array, and each entry carries code, message and detail. The detail is the sentence naming the media type. Registry access logs show the status but usually not the body, so the client-side repeat is the quicker route.
Is a 400 on a push ever worth retrying?
No. A 400 from the manifest endpoint means the registry parsed your request and declined its content, which will be identical on the next attempt. Retries are the right answer for the 5xx range, where the registry did not get far enough to judge the request. Spending runner minutes on a retry here buys the same 400 twice.

Related guides

References

A 400 repeats. A 502 does not. Latchkey runners tell the two apart before you re-run the job. Start free → 30-day trial · No credit card