# Exec user process caused: permission denied in Docker CI

> Exec user process caused: permission denied in Docker CI is a missing executable bit on the entrypoint, not a user problem. Learn the one-line fix.

Source: https://latchkey.dev/learn/docker/docker-standard-init-linux-exec-permission-denied-in-ci  
Updated: 2026-09-21

Exec user process caused: permission denied means runc found the entrypoint exactly where you said it would be and the kernel refused to execute it, which in a container almost always means the file has no executable bit. It is the same construction as the missing-file version of this error with one difference that decides everything: the errno at the end is 13 rather than 2.

## What this error means

The container exits immediately, before any of your own logging runs, and the message names a Go source file and a line number from runc rather than anything you wrote. Two public reports of this exact failure quote it as `standard_init_linux.go:228` and as `standard_init_linux.go:211`, and they render the tail differently too, one with a colon and one with quotation marks. Both are the same problem. The file itself is present in the image and a listing will confirm that, which is what makes the message feel wrong until you look at the mode column.

```The line seglo/kafka-lag-exporter#431 reports (quoted, not a recorded run)
standard_init_linux.go:228: exec user process caused: permission denied
```

## Common causes

### The script was committed without the executable bit

The dominant cause. Git tracks the bit and nothing else about the mode, so a file added without it is copied into every image without it. The review that introduced the script showed its contents and not its mode, which is why this survives code review reliably.

### The file was generated or extracted during the build

A script produced by an earlier stage, unpacked from a tarball, or written inline in the Dockerfile has the default mode rather than an executable one. Nothing lost the bit here; it was never set, which is a different story with the same ending.

### The entrypoint is a binary without the bit

The same failure applies to compiled programs, and it is easier to miss because people associate chmod with scripts. An artifact downloaded in the build and copied into the final stage commonly arrives non-executable, in our experience more often than a checked-in script does.

### A mount or a security policy is blocking execution

Rarer, and worth knowing so you do not chase a mode that is already correct. A volume mounted with noexec, or a policy that forbids executing from a path, produces the same errno with the bit plainly set. Check the mode first, because it is one command, and look here only when the mode is right.

## How to fix it

### Set the mode as the file enters the image

1. Use `COPY --chmod=755` so the mode is correct no matter what the build context held.
2. Name the entrypoint by absolute path, so the error would name a path you can check.
3. Rebuild and run the image once with no arguments to confirm it starts.

```Dockerfile
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
```

### Fix the bit in the repository too

Record the executable bit in git so every build from this tree agrees, including ones on machines that do not preserve modes. One command sets it and the change shows up in the diff as a mode change, which is exactly where the next reviewer should see it.

```Terminal
git update-index --chmod=+x entrypoint.sh
git ls-files -s entrypoint.sh   # expect 100755
```

### Chmod in the Dockerfile when COPY --chmod is not available

On a builder without BuildKit, or when the file is produced rather than copied, set the mode in a RUN step instead. Do it in the same stage that ships, because a mode set in a builder stage does not follow an artifact that a later COPY brings across.

```Dockerfile
RUN chmod 0755 /usr/local/bin/entrypoint.sh \
 && /usr/local/bin/entrypoint.sh --version
```

### Check the mode in the job that builds the image

One listing after the build tells you the mode of the entrypoint before anything tries to run it. This catches the generated-file and downloaded-binary cases that a repository check cannot see, because those files do not exist until the build runs.

```Terminal
docker run --rm --entrypoint sh "$IMAGE" -c 'ls -l /usr/local/bin/entrypoint.sh'
```

## How to prevent it

- Always use COPY --chmod for entrypoints and wrapper scripts.
- Keep the executable bit recorded in git, and review mode changes as changes.
- Set the mode in the stage that ships, not in a builder stage.
- Smoke run the image in the job that built it, so a bad mode never reaches a deploy.

## Two renderings, one failure, and what that proves

The two wordings in the wild are not two bugs. runc formats system errors from a stack frame plus a cause plus the underlying error, and that format string changed once: through v1.0.0-rc90 the final part was rendered as a quoted string, and from v1.0.0-rc91 it became a colon and a bare string. So `exec user process caused "permission denied"` and `exec user process caused: permission denied` differ only in which runc produced them.

The line numbers differ for the same kind of reason. They are captured from the stack at the moment the error is created, so they point into whatever runc build you are running and are not part of any message. This is worth knowing before you search: the whole line is assembled at run time and appears as a literal in no source file, so finding nothing when you search all of it is expected rather than suspicious.

| The two renderings in the wild | Which runc produced it |
| --- | --- |
| `exec user process caused "permission denied"` | v1.0.0-rc2 through v1.0.0-rc90 |
| `exec user process caused: permission denied` | v1.0.0-rc91 through v1.0.0 |
| Neither, and no Go file name at the front | v1.1.0 and later, which report the exec path and errno directly |

> If the tail reads `no such file or directory` instead, the errno is 2 and the target or its interpreter is absent: that is [exec user process caused: no such file or directory](/learn/docker/docker-standard-init-linux-exec), and chmod will not help there.

## Where the executable bit goes missing

A file copied into an image keeps the mode it had in the build context, and a build context is a git checkout. Git records only whether a file is executable, and it records that faithfully, so a script committed without the bit arrives in every image without it forever. Nobody notices at review time because the diff shows the content and not the mode.

The other route is a file that never had a mode to keep. Scripts extracted from an archive, generated by an earlier step, or written by a heredoc in the Dockerfile all start at the default mode, which is not executable. In both cases the fix is the same and it belongs in the image, not in the workflow.

```Terminal
# what the image actually has
docker run --rm --entrypoint sh "$IMAGE" -c 'ls -l /entrypoint.sh'
# and what git recorded, which is where it came from
git ls-files -s entrypoint.sh
```

## Set the mode where the file enters the image

The cleanest fix is a single flag. BuildKit lets COPY set the mode as the file lands, so the image is correct without a second layer and without depending on what the checkout did. That one change removes the whole class, including the case where somebody clones the repository on Windows and the mode never existed.

Fix it in git as well, so that local runs and other images built from the same tree agree with the one you just fixed. The two together mean the bit is right in the repository and right in the image regardless of how either is produced.

```Dockerfile and Terminal
# in the Dockerfile
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

# and once, in the repository
git update-index --chmod=+x entrypoint.sh
```

## What the runner does about it

No repair, and none is claimed: the slug is not in our heal evidence, so the page may not say a runner fixes it. There is no recorded run either, and the argument for this one is narrow and specific. This failure is a single bit on a single file inside an image the runner did not build. A capture would show one line, and that line is already quoted verbatim in two public issues at two different line numbers and in two different renderings, which is worth more than a third copy: the pair is what proves the line number and the punctuation are not part of the message. A run of our own would add a fourth variant and no new information.

There is also nothing here for a runner to repair even in principle. Changing the mode of a file inside somebody else's image, between attempts, would be modifying the artifact under test, which is the one thing CI must not do.

## FAQ

### Why do some reports show quotes around "permission denied" and others a colon?

Because runc changed the format string. Through v1.0.0-rc90 the underlying error was rendered as a quoted Go string; from v1.0.0-rc91 it became a colon followed by the bare text. The failure is identical, and a log check should match on the words rather than on the punctuation around them.

### The file is definitely in the image. Why does it say permission denied?

Because being present and being executable are different things. The kernel found the file and refused to execute it, which is errno 13, and a container image copies whatever mode the build context had. Run `ls -l` inside the image and look at the mode column rather than at the name.

### Do I need to change the user the container runs as?

Almost never. This is about the mode on the file, not about who you are: an entrypoint with no executable bit at all cannot be run by root either. Change the user only after `ls -l` has shown you a mode that is executable for some users and not for the one you are running as.

### Is this the same error as exec user process caused: no such file or directory?

It is the same sentence with a different ending, and the endings mean opposite things. Permission denied is errno 13 and says the target is there and cannot be executed, so the fix is a mode. No such file or directory is errno 2 and says the target or its interpreter is missing, so the fix is in what the image contains.

## References

- [runc v1.0.0: the format string behind both renderings, libcontainer/generic_error.go](https://github.com/opencontainers/runc/blob/v1.0.0/libcontainer/generic_error.go)
- [seglo/kafka-lag-exporter#431: the colon rendering at line 228](https://github.com/seglo/kafka-lag-exporter/issues/431)
- [banzaicloud/drone-kaniko#38: the quoted rendering at line 211, same failure](https://github.com/banzaicloud/drone-kaniko/issues/38)
- [Docker docs: Dockerfile reference, COPY and the --chmod option](https://docs.docker.com/reference/dockerfile/)

---

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
