Docker build lstat permission denied in CI
Docker build lstat permission denied is a line assembled across a process boundary: the sender that walks your build context hit a file it could not stat, and the receiver inside BuildKit printed that text with a prefix of its own. The words lstat and permission denied are the Go standard library, so searching the whole line finds nothing and proves nothing.

What this error means
A build fails while loading the context or the build definition, before any instruction runs, naming a path you never mentioned in the Dockerfile. It is often in a temporary directory, sometimes a mount point, occasionally under the Docker data root. Nothing you COPY refers to it. The block below is quoted from a reported issue, not from a run of ours.
#1 [internal] load build definition from container2wasm3891124366
#1 transferring dockerfile: 53B done
#1 ERROR: error from sender: lstat /tmp/.mount_jetbraxn9mWC: permission denied
------
> [internal] load build definition from container2wasm3891124366:
------
ERROR: failed to solve: failed to read dockerfile: error from sender: lstat /tmp/.mount_jetbraxn9mWC: permission deniedThree programs, one line, no literal
BuildKit does not read your build context directly. A sender on the client side walks the directory and streams entries to a receiver inside the builder. When the walk fails, the sender puts its error text on the wire, and the receiver formats what it got as the words error from sender, a colon and the text. That is a single format string with one placeholder in it.
The text in the placeholder was produced in the other process, by Go, when a stat call failed. Go renders that as the operation name, the path and the underlying error, which is where lstat and permission denied come from. Neither program contains the sentence you pasted. It exists only when the two halves are joined at runtime, which is why a literal search for it returns nothing and why that absence tells you nothing at all.
| Piece of the line | Where it was produced |
|---|---|
failed to read dockerfile: or failed to load build context: | BuildKit, naming which internal step was running |
error from sender: | The file transfer library, in the receiver inside the builder |
lstat <path>: | The Go standard library, formatting a path error in the sending process |
permission denied | The Go standard library, rendering the underlying system error |
Common causes
An entry in the context directory the client cannot stat
The dominant cause, and the one the quoted log shows: a mount point, a socket, a directory owned by another user, or a path whose permissions are stripped by whatever created it. It does not have to be anything your Dockerfile mentions, because the walk covers the whole tree.
The build context is far wider than it needs to be
A context set to a home directory, a workspace root or the filesystem root will eventually include something unreadable, and on a shared or self hosted runner it will include something unreadable fairly quickly. This is the version of the problem that keeps coming back, because each individual unreadable path looks like a one off.
A rootless or non root builder next to root owned paths
When the process doing the walk is not root, anything root owned with restrictive permissions stops it. On a self hosted runner this often means files left by a previous job that ran in a container as root. The same context walks fine as root and fails as the runner user, which is a confusing pair of results to compare.
You are looking at a storage error that merely mentions the same directory
A daemon failing to register a layer, or a builder reading a stage snapshot, both produce paths under the Docker data root and neither is a context problem. Check the words around the path before you go and change your build context. In our experience this misreading costs more time than the real cause does.
How to fix it
Narrow the context before you chase the path
- Set the context to the smallest directory containing what the build needs.
- Pass it explicitly rather than relying on the working directory or an action default.
- Confirm with the transferring context line in the build output, which prints how much was sent.
- uses: docker/build-push-action@v7
with:
context: ./services/api
file: ./services/api/DockerfileExclude the offending path so the walk never reaches it
Excluded paths are removed from the context before it is sent, so an ignore entry stops the walk failing rather than hiding a failure. An allowlist is the version that keeps working when the next unreadable thing appears, because it admits only what you named.
# .dockerignore
*
!src/
!package.json
!package-lock.jsonFind the entry, in the job, with the same user the build runs as
The path in the message is the one that failed, but there are usually more behind it. Listing the context as the build user finds the rest in one step, which is worth doing once on a self hosted runner that has been in service for a while.
- name: Find unreadable entries in the context
run: find . -xdev \( -type d -o -type f \) ! -readable -print 2>/dev/null | head -50Clean up after jobs that run as root on self hosted runners
The durable fix on a machine you own is to stop root owned leftovers accumulating in workspaces, rather than excluding each one as it appears. A cleanup step that runs regardless of outcome keeps the next job from inheriting them.
- name: Reset workspace ownership
if: always()
run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" || trueThe walk covers the whole context, not just what you copy
This is the part that surprises people. The sender walks the context directory and streams what it finds; it does not consult your Dockerfile first and fetch only the paths you asked for. So a single unreadable entry anywhere under the context root fails the transfer, even when no instruction would ever have touched it.
In the reported log above, the failure happens during loading the build definition, because that transfer walks the directory holding the Dockerfile. The path that broke it is an application mount point in a temporary directory that had nothing to do with the build. A .dockerignore entry is the fix precisely because excluded paths are removed from the context before the walk reaches them.
The Docker data root flavor is usually a different message
A search for this error with /var/lib/docker in it turns up plenty of results, and most of them are not this error. The common one is the daemon failing to register a layer with a path under the overlay directory and the words no such file or directory, which is a storage problem rather than a context problem. Another, reported against BuildKit itself, is a COPY --from reading a stage snapshot through that directory, where the path is the merged view of a stage and not your context at all.
So do not conclude from the directory name that your build context includes the Docker data root. Read what the message says was doing the work. Loading a context or a build definition points at a walk on the client side; registering a layer or copying from a stage points at the daemon or the builder reading its own storage.
# a context walk failing on an entry the client cannot stat
ERROR: failed to solve: failed to read dockerfile: error from sender: lstat /tmp/x: permission denied
# a different failure entirely, from the daemon storing a layer
failed to register layer: lstat /var/lib/docker/overlay2/<id>/merged/...: no such file or directoryWhy no recorded run backs this page
To record this we would have to create an unreadable file on the runner, build next to it and publish the result. That records our ability to run chmod. Worse, the interesting cases in the wild are all about the environment the client happens to be in, a desktop mount point, a leftover temporary directory owned by root, a rootless builder next to a root owned path, and none of those are reproducible on a clean runner by design.
What the page rests on instead is a real reported log, which we quote and attribute, and the three format strings that produce the line, which we can point at individually. That combination is stronger than a manufactured run, because it explains why the line cannot be searched and a run would not have explained anything.
How to prevent it
- Point the build context at the narrowest directory that contains what the build needs.
- Keep .dockerignore as an allowlist so a new unreadable path cannot break the walk.
- Clean workspace ownership after jobs that run containers as root on self hosted runners.
- Read the words around a path before concluding which component produced the message.
Frequently asked questions
Why does my build fail on a file I never copy?
What does error from sender mean in a Docker build?
Does this mean my build context includes /var/lib/docker?
Why does the build work as root and fail as the runner user?
Related guides
References
- container2wasm/container2wasm#324: the reported log quoted on this page
- orbstack/orbstack#1515: the same prefix with a different sender error
- moby/buildkit#1034: a data root path from a stage snapshot, not a context walk
- Docker docs: the build context and .dockerignore
- Docker documentation
- Docker build cache
- GitHub Actions documentation