DevOps

Fix Docker Compose 'Exec format error' loading a library

Your app crashes on docker-compose up while it works fine outside Docker — here is why a native module refuses to load, and the three fixes that actually remove it.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

docker-compose up crashes with Error loading shared library ...: Exec format error when a dependency ships a compiled .node binary that was built outside the container — typically because node_modules was installed on the host (macOS, Windows, or a different Linux) and then copied into the image unchanged. The fix is to make sure every native binding gets compiled inside the Linux container: delete node_modules and reinstall there, stop it from ever being copied in with .dockerignore, and if a package has no Linux build at all, replace it.

The error#

This is the exact failure from the original report (Stack Overflow #45549193), a Node.js app that depends on the natural package, which in turn pulls in webworker-threads:

text
web_1         | /app/web-server/node_modules/natural/lib/natural/classifiers/classifier.js:32
web_1         |     if (e.code !== 'MODULE_NOT_FOUND') throw e;
web_1         |                                        ^
web_1         |
web_1         | Error: Error loading shared library /app/web-server/node_modules/webworker-threads/build/Release/WebWorkerThreads.node: Exec format error
web_1         |     at Error (native)
web_1         |     at Object.Module._extensions..node (module.js:597:18)
web_1         |     at Module.load (module.js:487:32)
web_1         |     at tryModuleLoad (module.js:446:12)
web_1         |     at Function.Module._load (module.js:438:3)
web_1         |     at Module.require (module.js:497:17)
web_1         |     at require (internal/module.js:20:19)
web_1         |     at bindings (/app/web-server/node_modules/bindings/bindings.js:81:44)
web_1         |     at Object.<anonymous> (/app/web-server/node_modules/webworker-threads/index.js:1:105)

WebWorkerThreads.node is a compiled native addon, not JavaScript. The stack trace shows it being pulled in through bindings.js, the small helper most native Node modules use to locate their compiled .node file at require() time.

Why it happens#

A .node file is a shared library (an .so on Linux, a .dylib on macOS, a .dll-wrapped binary on Windows) that Node loads with its internal process.dlopen(). Like any shared library, it is built for one combination of operating system, CPU architecture and C library (glibc vs musl on Alpine) — it is not portable the way plain JavaScript is.

The accepted answer on the Stack Overflow thread names exactly this: "It sounds like it's trying to load a native extension that wasn't compiled for linux (in the container) — maybe you previously installed the extension on OS X and it's trying to load that binary", pointing to a matching report in the dinghy issue tracker. If node_modules is installed once on a developer's host and then COPY-ed, bind-mounted, or otherwise carried unchanged into the Linux container, every native binding it contains keeps the binary built for the host, and the container's dynamic loader refuses to run it.

Reproducing the loading mechanism without needing the exact same native package: a .node file is loaded with Node's process.dlopen(), which hands off to the platform's dynamic linker just like loading any other shared library. Compiling the same trivial C source once for linux/arm64 and once for linux/amd64 (via docker run --platform, Docker Desktop, 8 October 2026) and require()-ing each .node file from inside an amd64 node:20-bookworm-slim container isolates the failure to the loading step itself: the arm64 build fails immediately with Error: /app/arm64addon.node: cannot open shared object file: No such file or directory (on musl/Alpine the wording differs — Error relocating ...: unsupported relocation type 1026 — but it is the same dynamic-loader rejection), while the matching amd64 build passes that same loading step and only fails later, for an unrelated reason (the stub never calls NODE_MODULE(), so Node correctly reports "Module did not self-register"). Changing nothing but the build platform moves the failure from the loading stage to a later, architecture-unrelated one — which is the mechanism the accepted answer describes for webworker-threads.

Fix#

1. Rebuild node_modules inside the container, not on the host#

The fix that resolved the original report was simply removing the host-built node_modules and letting the container reinstall it: Running npm install inside the build can surface a different failure on node:20.3.0 images, where esbuild's install script stops with Text file busy, and the esbuild spawnSync ETXTBSY fix for Docker images covers moving to a newer image or setting UV_USE_IO_URING=0 before the install.

bash
# inside the Dockerfile, or once in a running container while debugging
rm -rf node_modules
npm install

In a Dockerfile, that means npm install (or npm ci) has to run as a RUN step that executes inside the build, after COPY package*.json ./, and before any COPY . . that could carry a host-built node_modules over it:

dockerfile
FROM node:20-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "server/Server.js"]

Then rebuild without the Docker layer cache for that step:

bash
docker compose build --no-cache
docker compose up

2. Stop the host's node_modules from ever reaching the image#

Even with the Dockerfile above, a node_modules folder sitting in the build context next to the Dockerfile still gets uploaded and can shadow files during COPY . . if the order of the two COPY instructions is wrong, or if a later step copies more than the Dockerfile lists. The second answer on the thread is the direct fix: add a .dockerignore file next to the Dockerfile.

text
/node_modules

This guarantees the host's compiled binaries are never part of the build context in the first place, so the only node_modules that can exist in the image is the one npm install built for it.

3. Replace a package that has no usable native build for your base image#

Sometimes the native dependency itself is the dead end. One answerer on the thread reports that no amount of rebuilding fixed it, because the real problem was a specific package, bcrypt, that needed its native binding recompiled for their particular base image; switching to the pure-JS bcryptjs removed the native binding entirely:

bash
npm uninstall bcrypt
npm install bcryptjs

This is a last resort, not a first step — bcryptjs is slower than the compiled bcrypt and the two libraries are not always drop-in API matches. Try steps 1 and 2 first; reach for a pure-JS replacement only when the image genuinely has no working toolchain to rebuild the native binding (a minimal distroless-style runtime image, for example, or a package whose native build has been abandoned upstream).

Verify the fix#

bash
docker compose up

The container should start the application without the Error loading shared library stack trace. If it still appears after steps 1 and 2 above, check the docker-compose.yml for a volumes entry that bind-mounts the project root over the app directory, such as volumes: ['.:/app']. Docker's own documentation on bind mounts states plainly: "If you bind mount a file or directory into a directory in the container in which files already exist, the pre-existing files are obscured by the mount." That means a bind mount of the whole project directory re-obscures the freshly-built node_modules inside the image with the host's folder at container start — undoing fix 1 every single time docker-compose up runs, even though the image itself was built correctly. The common way around it, without giving up the bind mount for live-reload during development, is adding a named anonymous volume for node_modules specifically, so it is excluded from the bind mount and keeps the one built inside the image:

yaml
services:
  web:
    build: .
    volumes:
      - .:/app
      - /app/node_modules

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Variants worth knowing#

The same error string shows up for a different reason once the whole image — not just one package inside it — is built for the wrong CPU architecture, typically an arm64 image (built on an Apple Silicon Mac) run on an amd64 host or task. That failure mode, and the docker buildx --platform fix for it, is covered separately — worth checking first if the error appears on the entrypoint itself rather than from inside a require() call.

Two other native-binding errors follow the same root cause as this one and are worth knowing if natural/webworker-threads is not the package involved: Node Sass's "could not find a binding for your current environment", and Rollup/Vite's "Cannot find module @rollup/rollup-win32-x64-msvc", which is the same mismatch running the other direction (a Linux-built node_modules committed or synced onto a Windows machine). In both, the fix is the same first move: delete node_modules and reinstall it on the machine — or container — that actually runs the code.

A docker-compose up that fights a native binding on every rebuild is rarely a one-off: it is a sign the project's Dockerfile and its .dockerignore disagree about where node_modules should come from. Fixing the Dockerfile's COPY order once, and adding the .dockerignore line, removes the whole class of error for every dependency that ships a compiled binary, not only the one that happened to surface it first.

Further reading: Fix RabbitMQ ECONNREFUSED 5672 between Docker containers. Also see Fix Docker: Client Version Is Too Old.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote