# Docker build too many open files in CI

> Docker build too many open files is your program reporting EMFILE, not Docker. Measure the limit inside the step first: it may already be enormous.

Source: https://latchkey.dev/learn/docker/docker-ulimit-too-many-open-files-build  
Updated: 2026-09-21

Docker build too many open files is the C library rendering the EMFILE errno for whatever program you ran, so the sentence belongs to your compiler or bundler rather than to Docker. Before you raise a limit, measure it: BuildKit deliberately clears the container runtime default, and on the builder we measured the limit inside a RUN step was already over a million.

## What this error means

A RUN step fails partway with a message containing the words too many open files, sometimes prefixed with EMFILE, sometimes from a compiler, a bundler or a test runner. The same command succeeds outside the build. Nothing in the message comes from Docker, which is why raising a Docker flag is as likely to make it worse as better. The two measurements below are from this machine.

```Reconstructed from the formatting of the Node.js EMFILE message and the BuildKit step reporter; the two ulimit lines below are captured locally on Docker 29.6.2 and buildx v0.35.0-desktop.2, 2026-09-21
#11 8.42 Error: EMFILE: too many open files, open '/app/node_modules/.pnpm/typescript/lib/tsc.js'
#11 ERROR: process "/bin/sh -c npm run build" did not complete successfully: exit code: 1
--- and here is what the limit actually was, measured with ulimit -n in a RUN step
#5 0.133 1048576
#5 0.110 65536        <- the same build with --ulimit nofile=65536:65536
```

## Common causes

### The tool genuinely needs more descriptors than the builder allows

A large TypeScript project, a bundler holding every module open, or a test runner with many watchers can exceed a modest limit honestly. This is the case the standard advice is written for, and on a builder with a low inherited limit raising it is the right answer. Confirm it by measuring rather than assuming.

### A ulimit flag lowered the limit below what the step needs

We measured this directly: a nofile of 65536 replaced a limit of 1048576. Anyone who added the flag as a precaution, or copied it from advice written for a different builder, has capped the step. If the failure appeared when the flag did, remove the flag before you raise it further.

### Too much is running at once inside one step

Descriptor exhaustion is a peak problem, so a build that succeeds on a quiet machine and fails on a busy one is usually about concurrency rather than about the ceiling. Parallel compilation multiplies the peak by the number of workers, and BuildKit running several steps at once multiplies it again.

### The program leaks descriptors

A watcher that is never closed, a loop opening files without closing them, or a dependency with a known leak will exhaust any ceiling given enough time. The tell is that the failure moves later as you raise the limit instead of going away. In our experience this is the minority case but it is the one where raising limits wastes the most time.

## How to fix it

### Print the limit from inside the failing step

1. Add a line to the RUN that prints the soft and hard limits before the real command runs.
2. Compare it against what your tool needs. If it is already in the hundreds of thousands, the ceiling is not your problem.
3. Keep the line in the Dockerfile while you are debugging, so the next failure reports the limit alongside it.

```Dockerfile
RUN ulimit -n && ulimit -Hn && npm run build
```

### Raise it only after measuring, and raise the hard limit too

The flag takes a soft and a hard value separated by a colon. Setting only the soft value leaves the hard value wherever it was, so a tool that tries to raise its own limit at startup will still be capped. Pass both, and pass a number above what you measured rather than a round one from a blog post.

```Terminal
docker build --ulimit nofile=1048576:1048576 -t acme/api:ci .
```

### Turn the concurrency down instead, where you cannot change the builder

This is the lever available on a hosted runner. Reducing the parallelism of the compiler inside the step and the parallelism of the builder outside it both reduce the peak, and neither needs a flag your platform may not honor.

```Dockerfile
# inside the step
RUN npm run build -- --max-workers=2

# and outside it, on the builder
# env.BUILDKIT_MAX_PARALLELISM=2
```

### Fix it once on a runner image you own

On self hosted infrastructure, the limit buildkitd runs under is the limit every build step inherits, because BuildKit clears the runtime default rather than replacing it. Setting it there means no build needs a flag and no new Dockerfile can forget one.

```systemd drop-in
# /etc/systemd/system/docker.service.d/limits.conf
[Service]
LimitNOFILE=1048576
```

## How to prevent it

- Print the descriptor limit in any build step that has ever run out, and leave the line there.
- Do not carry a ulimit flag between projects without measuring what it replaces.
- Set the limit on the daemon of any runner image you own, rather than per build.
- Cap compiler and builder parallelism on small runners so the peak stays inside the ceiling.

## Measure first, because the default may already be enormous

BuildKit does not hand your build step the container runtime default. When no ulimits are set for a build, it clears the resource limit block on the OCI specification entirely, with a comment in the source saying it is resetting the open files limit. Clearing it means the step inherits whatever buildkitd itself is running under, rather than the 1024 soft limit a container runtime would otherwise apply.

What that works out to depends on your builder. We measured it directly with a one line RUN step and got 1048576 for both the soft and the hard limit. Setting the flag people reach for, a nofile of 65536, took the limit down by a factor of sixteen. If your builder is configured like this one, the standard advice is not neutral: it is a reduction.

| How the build was invoked | `ulimit -n` soft | `ulimit -n` hard |
| --- | --- | --- |
| `docker build` with no ulimit flag | 1048576 | 1048576 |
| `docker build --ulimit nofile=65536:65536` | 65536 | 65536 |

> Measured on this machine on 2026-09-21, Docker 29.6.2 with buildx v0.35.0-desktop.2, from a two line Dockerfile running `ulimit -n && ulimit -Hn`. Your builder may differ, which is the point of measuring.

## The words are POSIX, not Docker

Too many open files is the standard text for the EMFILE error number, which a process gets when it asks for a file descriptor and has none left. Every language renders it slightly differently. Node prefixes it with EMFILE and the operation, Go wraps it in a path error, Java says it in an IOException, and a shell tool may just print the C library string. None of those sentences originate in Docker or BuildKit.

That is a practical point rather than a pedantic one. It means the fix lives with whichever program ran out, and it means a search for the message will find results from every ecosystem. Search with the name of your tool attached, and treat any result that only talks about Docker flags with suspicion until you have measured your own limit.

## Descriptors are also consumed by how much runs at once

A build step does not have one descriptor consumer. A parallel compiler, a watcher, a test runner and a bundler can all be inside the same RUN, and BuildKit may be running several steps at once besides. Lowering how much happens concurrently reduces the peak without touching any limit, which is the fix that works when you cannot change the builder.

On a builder you do control, raising the limit on buildkitd itself is the durable version, because every step then inherits the higher number without a flag on every build. On a hosted runner you do not control it, so the concurrency lever is the one that is actually available to you.

```.github/workflows/build.yml
# cap how many build steps run at once on a container builder
- uses: docker/setup-buildx-action@v4
  with:
    driver-opts: |
      env.BUILDKIT_MAX_PARALLELISM=2
```

## Why no recorded run backs this page

The one thing worth recording here is a number, and a number from a Latchkey runner would be a fact about Latchkey runners rather than about yours. Publishing it would invite exactly the mistake this page is trying to prevent, which is treating one builder's limit as the limit. So the page publishes the measurement it can attribute precisely, from a named Docker version on a named machine, and tells you to take your own.

The mechanism claim is the part that generalizes, and it is in the source rather than in any run: BuildKit clears the resource limit block when no ulimits are given. That is true on every builder, and it is what makes the measured number unpredictable enough to be worth measuring.

## FAQ

### Does Docker print the words too many open files?

No. That is the standard text for the EMFILE error number, rendered by the C library or by your language runtime for whichever program ran out of descriptors. Docker passes the output through. The fix belongs with the tool that reported it, and the limit belongs with the builder.

### What is the open file limit inside a docker build step?

Whatever buildkitd is running under, because BuildKit clears the resource limit block on the container specification when no ulimits are set for the build. We measured 1048576 on Docker 29.6.2. It is not the 1024 that a container runtime would apply by default, and it is not predictable enough to assume.

### Can --ulimit nofile make my build worse?

Yes, and we measured it doing so. A build with no flag saw 1048576 descriptors and the same build with a nofile of 65536 saw 65536. If the failure started when someone added the flag, take it off before you consider raising it.

### Why does the same command work outside the build?

Because the limit outside the build is your shell's, inherited from your login session, and the limit inside it is buildkitd's. They are set by different things and there is no reason for them to match. Measuring inside the step is the only way to compare them honestly.

## References

- [BuildKit: the OCI spec code that clears the open files limit](https://github.com/moby/buildkit/blob/master/executor/oci/spec.go)
- [Docker docs: docker buildx build and the ulimit flag](https://docs.docker.com/reference/cli/docker/buildx/build/#ulimit)
- [POSIX: the EMFILE error number and its message](https://pubs.opengroup.org/onlinepubs/9699919799/functions/open.html)
- [Docker docs: configure the daemon with systemd](https://docs.docker.com/engine/daemon/proxy/#systemd-unit-file)

---

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
