Skip to content

Trivy Could Not Find the Image I Had Just Built

The build step succeeded. The next step scanned the image it had just produced and reported that no such image existed. Both steps were correct: the image was real, and it was not anywhere Trivy knew how to look. This is what load: true actually does, and why a scanner that fails to find an image is a more dangerous failure than one that finds vulnerabilities.

The Error

FATAL โ€” unable to find image "mibtisam/bankapp:0.0.1-SNAPSHOT-f7ff8dc-12"
* docker:  No such image
* remote:  MANIFEST_UNKNOWN โ€” unknown tag

The tag in that message is correct. It is the tag the previous step had just built and logged. Pulling the workflow logs and reading them in order showed a successful build, then an immediate failure to find its output.

Four Backends, Four Different Failures

The useful thing about that error is that Trivy tells you it tried more than one place. It resolves an image reference by walking a list of backends and taking the first that answers:

Backend What happened
docker Reached the daemon fine. The daemon genuinely did not have the image.
containerd permission denied on the socket. Runner user has no access.
podman No socket. Podman is not installed on the runner.
remote Queried Docker Hub. MANIFEST_UNKNOWN, because the image had not been pushed yet and would not be until after the scan passed.

Every one of those is the correct behaviour for that backend. None of them is a bug. The image existed in a fifth place that was not on the list.

What load: true Means

The build step looked like this:

- name: Docker Build
  uses: docker/build-push-action@v6
  with:
    push: false
    load: true

push: false because the whole point is to scan before publishing. load: true because the image has to exist locally for a local scan. That reads as correct and it is what the documentation suggests.

The gap is that docker buildx does not build into the Docker daemon. It builds inside BuildKit, which maintains its own content-addressable store, isolated from the daemon's image store. load: true asks BuildKit to export the result back into the daemon afterwards, and when it works, docker images shows the tag.

When it does not work, or when the builder instance is configured such that the export lands somewhere Trivy's docker backend does not query, you get exactly this: a build that reports success, an artifact that exists, and a scanner that cannot see it.

The part worth internalising

docker build and docker buildx build produce the same image and put it in different places. Every tool downstream that takes an image reference (scanners, signers, SBOM generators, docker run smoke tests) is querying the daemon. Buildx is not obliged to have put anything there.

Why This Is Worse Than a Failing Scan

A Trivy run that reports twelve criticals is doing its job. A Trivy run that cannot find its target is a gate with nothing behind it, and the failure mode depends entirely on one argument you set somewhere else.

With exit-code: '1', the step fails and the pipeline goes red. Loud, obvious, someone investigates. That is what happened here, which is the only reason I found it.

With exit-code: '0', which is a completely reasonable setting for an advisory pass and which the OS-package pass legitimately uses, the step prints FATAL: unable to find image, exits zero, and the pipeline goes green. The log line is there. Nobody reads passing logs. The build ships with a security step that ran, found nothing, and was structurally incapable of finding anything.

That is a strictly worse outcome than having no scan at all, because the workflow file still lists a scanning step and the run still shows a green check.

A scan step that cannot fail cannot be trusted to have run. If a pass is advisory by design, the thing to assert is not the severity threshold but that the scanner actually resolved its target.

The Fix

Build with plain docker build, which puts the image in the daemon by definition:

docker build \
  --tag ${{ env.IMAGE_NAME }}:${{ steps.versioning.outputs.image_tag }} \
  --tag ${{ env.IMAGE_NAME }}:latest \
  --build-arg SERVER_PORT=8000 \
  ${{ env.APP_DIR }}

Scan it. Push it afterwards with docker push, once the scan has passed.

This trades away BuildKit's cache exports and multi-platform support, which for a single-arch amd64 image being scanned locally is a trade worth making. The Java workflow today has since moved back to docker/build-push-action@v6 with load: true and a GHA layer cache, because that combination does work on the current runner image, and the caching is worth real minutes. The constraint that made the original failure possible has not gone anywhere; it is documented in the workflow rather than discovered again:

# CONSTRAINT: load: true is INCOMPATIBLE with multi-platform builds.
# If you ever add platforms: linux/amd64,linux/arm64, Docker Buildx
# cannot load a multi-platform manifest into the local daemon and will error out.

That comment is the actual deliverable from this bug. The build-then-scan-then-push shape only holds while the image is single-platform.

What Happens When You Add A Second Architecture

Worth working through, because the constraint is not obvious until it bites and the workarounds are all compromises.

Buildx cannot load a multi-platform manifest into the daemon. The daemon's image store holds one image per tag, not a manifest list, so load: true and platforms: linux/amd64,linux/arm64 are mutually exclusive. That leaves three options and no clean one:

Scan one architecture, publish both. Build amd64 with load: true, scan it, then rebuild multi-arch with push: true. The scan is real but covers one of the two artifacts you ship. Acceptable when the architectures share a base image and a dependency set, which is the common case, and dishonest if you present it as having scanned the release.

Push first, then scan the registry. Trivy resolves a remote reference perfectly well. The cost is that the vulnerable image exists in the registry before you know whether it is vulnerable, which inverts the entire point of gating before publish. Survivable if you push to a staging tag first and promote afterwards, which is more pipeline than most projects want.

Scan the filesystem instead. Run the scan against the build context rather than the image. Cheap, architecture-independent, and it misses everything the base image contributes, which is usually where the OS findings are.

The DebugBox pipeline hits this same wall and takes the first option deliberately: scan on amd64, rebuild for both on push. That is the pragmatic answer, and it is worth naming as a trade rather than treating a single-arch scan as complete coverage.

The Guard That Anticipates This

One detail in the current workflow is a direct response to this class of failure. The library pass deliberately omits if: always():

# Pass B has no if: always() โ€” intentional. If Stage 9 (Docker build)
# fails, there is no image to scan; running trivy image against a
# non-existent image produces a misleading "image not found" error on
# top of the real build failure.

Adding if: always() to a scan step looks like diligence. Here it would mean that any Docker build failure produces two red steps: the real one, and a scan complaining it cannot find an image that was never built. The second error is louder, more recent, and points at the wrong thing.

The passes that do carry if: always() are the advisory and reporting ones, which should still run and upload their artifact when something later in the job failed. The distinction is whether the step can produce meaningful output when its input is missing. A report can. A gate cannot.

The Generalisation

Three things came out of this that outlive the specific tool versions.

Verify the handoff, not just the steps. Two steps that each succeed can still fail to connect. Build produced an image and scan consumed an image reference; nothing in either step's success criteria checked they were talking about the same object. A docker image inspect between them would have caught this in one line.

Read the whole error, including the parts about tools you are not using. The podman and containerd lines looked like noise. They were the diagnosis: a list of everywhere Trivy had looked, which by elimination named the one place it had not.

"It is in the local daemon" is an assumption, not a fact. It was true for years, before Buildx became the default builder. Most instructions written against docker build still carry that assumption silently, and it stops holding the moment a build tool grows its own storage layer.

Source


Series: CI Pipeline Engineering Across Three Applications (Part 3 of 8)