This message is a summary, not the error. BuildKit is telling you which RUN failed and what it exited with. The reason is in the output above it, and BuildKit's default progress renderer collapses that output as it goes, so by the time the build fails the useful part has scrolled away.
Get the real error
docker build --progress=plain --no-cache -t myapp:1.0 .
--progress=plain prints every line instead of the collapsing TUI. --no-cache ensures the failing step actually executes rather than being replayed.
If the log is long:
docker build --progress=plain --no-cache . 2>&1 | grep -B30 "did not complete successfully"
You can also set it for the session:
export BUILDKIT_PROGRESS=plain
Read the exit code
Docker reports the program's exit code, and those are tool-specific. They narrow things down considerably:
| Code | Common meaning |
|---|---|
1 | Generic failure. Read the output |
2 | Misuse of a shell builtin, or a Go build error |
100 | apt-get could not install |
127 | Command not found |
137 | Killed, usually out of memory |
139 | Segmentation fault |
127 means the command does not exist in that image, not that it failed. 137 during npm ci or a webpack build is the builder running out of memory, which is a Docker Desktop resource setting rather than a Dockerfile problem.
Debug inside the failing layer
Every layer before the failure is already built and cached, so you can get a shell in that exact state.
With a multi-stage build, target the stage before the failure:
docker build --target builder -t debug:latest .
docker run --rm -it debug:latest sh
For a single-stage Dockerfile, add a temporary stage marker just above the failing instruction:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
FROM node:22-alpine AS debug # temporary
# RUN npm ci # the failing step
Then run the command by hand and read the output properly.
BuildKit also offers this directly, if you have buildx debug:
docker buildx debug --on=error build .
That drops you into a shell at the point of failure, which is the most direct version of the same idea.
The failures that account for most of these
apt-get install with a stale package index. Exit code 100.
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
update and install must be in the same RUN. Split across two, the update layer is cached, and weeks later the cached index refers to package versions that no longer exist on the mirror. The install then fails with 404 Not Found. This is the single most common cause of a Dockerfile that built fine last month and fails today with nothing changed.
-y is required too, since there is no terminal to answer the prompt.
npm ci without a lockfile.
npm ci can only install packages when your package.json and package-lock.json are in sync
npm ci requires package-lock.json and refuses if it disagrees with package.json. Check .dockerignore is not excluding the lockfile, which is a surprisingly common mistake:
COPY package.json package-lock.json ./
RUN npm ci
Out of memory. Exit 137 on a webpack, tsc or Go build. Raise the memory available to the builder, or cap the tool:
ENV NODE_OPTIONS=--max-old-space-size=2048
A file that is not in the build context. .dockerignore is applied before the build starts, so a COPY of an ignored file fails, or copies nothing and the next step fails.
docker build --progress=plain . 2>&1 | grep transferring
A context of a few kilobytes when you expected megabytes means .dockerignore is excluding more than you meant.
The build works locally and fails in CI
Three usual reasons:
Architecture. CI runners are amd64; an Apple Silicon Mac is arm64. A dependency with native bindings can build on one and not the other.
Cache. Your local build replays cached layers and never runs the failing step. docker build --no-cache locally reproduces what CI sees.
Context. .dockerignore interacts with what your checkout actually contains. A file present locally but gitignored is not in CI.
docker build --no-cache --progress=plain --platform linux/amd64 .
That single command eliminates all three.
Keeping the failure visible
A RUN with a pipeline only reports the last command's exit status by default, so a failure mid-pipeline is silently ignored:
RUN curl -fsSL https://example.com/install.sh | sh
If curl fails, sh still succeeds on empty input and the layer is committed. Turn that off:
SHELL ["/bin/sh", "-euxo", "pipefail", "-c"]
-e exits on error, -u on an undefined variable, -x echoes each command, and pipefail makes a pipeline fail if any stage does. Note pipefail is not available in the default sh on Alpine, where you need bash installed or /bin/ash.
A checklist
--progress=plain --no-cache. The summary is never the error.- Look up the exit code.
100is apt,127is not found,137is OOM. - Build the stage before the failure and run the command by hand.
apt-get update && apt-get installin oneRUN, always.npm ci→ confirm the lockfile is copied and not in.dockerignore.- Exit 137 → give the builder more memory.
- Works locally, fails in CI →
--no-cache --platform linux/amd64. - Add
SHELL ["/bin/sh", "-euxo", "pipefail", "-c"]so failures cannot hide.
Frequently Asked Questions
Where is the actual error in a "failed to solve" message?
Above it. The failed to solve: process ... did not complete successfully line is BuildKit's summary naming which instruction failed, and the real output came from the command itself further up. BuildKit's default progress renderer collapses output as steps complete, so by the time the build fails that text is gone from the screen. Rebuild with --progress=plain --no-cache to see every line, and read the thirty or so lines immediately before the summary.
Why does my Dockerfile suddenly fail when nothing changed?
Almost always a cached apt-get update layer. If update and install are in separate RUN instructions, Docker caches the package index and reuses it indefinitely. Weeks later the mirror has moved on, the cached index refers to versions that no longer exist, and the install fails with a 404 and exit code 100. Combining them into a single RUN apt-get update && apt-get install -y ... means the index is always refreshed alongside the install, which is why it is the standard idiom.
What does exit code 137 mean during a docker build?
The process was killed with SIGKILL, and during a build that almost always means it ran out of memory. Webpack, TypeScript and Go builds are the usual victims. Raise the memory available to the Docker builder, which on Docker Desktop is a setting in Resources, and consider capping the tool itself with something like ENV NODE_OPTIONS=--max-old-space-size=2048. In CI, the runner's memory is the limit, and a larger runner class is sometimes the only fix.
Why does the build succeed locally but fail in CI?
Three reasons, in order of likelihood. Cache: your local build replays the failing step from cache and never runs it, which --no-cache reproduces. Architecture: CI runners are amd64 while an Apple Silicon Mac is arm64, so a native dependency can build on one and not the other, which --platform linux/amd64 reproduces. Context: a file present locally may be gitignored and therefore absent in CI. Running docker build --no-cache --progress=plain --platform linux/amd64 . locally tests all three at once.
How do I get a shell inside a failing build step?
Every layer before the failing instruction is already built and cached, so build up to that point and run it. With a multi-stage Dockerfile use docker build --target <stage> and then docker run --rm -it <image> sh. With a single stage, temporarily add a FROM ... AS debug marker just above the failing RUN. If your Docker version has it, docker buildx debug --on=error build . does this automatically and drops you into a shell at the exact point of failure.