Diagnosing Native Module Build Failures on ARM64
npm ci inside an arm64 container stops with gyp ERR! stack Error: not found: make or prebuild-install warn install No prebuilt binaries found (target=20.17.0 runtime=node arch=arm64 libc=musl platform=linux); pip install spends eight minutes compiling grpcio from source and then fails with error: command 'gcc' failed: No such file or directory; or the container starts and crashes with Error: /app/node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node: invalid ELF header. These all come from native extensions — packages with compiled code — meeting an architecture or libc they were not built for. This page diagnoses which case you have and fixes each, as part of cross-platform container builds.
The failures cluster on Apple Silicon laptops and arm64 CI runners, but the root causes are the same on any architecture change.
Diagnostic
Identify the platform the install runs on, the libc, and whether a prebuilt binary exists for that combination:
#!/usr/bin/env bash
set -euo pipefail
docker compose run --rm --no-deps api sh -c '
echo "arch: $(uname -m)"
(ldd --version 2>&1 | head -1) || echo "libc: musl (no glibc ldd)"
node -p "process.versions.node + \" \" + process.arch + \" \" + (process.report.getReport().header.glibcVersionRuntime || \"musl\")"
file node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node 2>/dev/null || true
python3 -c "import sysconfig; print(sysconfig.get_platform())" 2>/dev/null || true
'
Expected bad output for the wrong-arch case:
arch: aarch64
libc: musl (no glibc ldd)
20.17.0 arm64 musl
node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node: ELF 64-bit LSB shared object, x86-64, dynamically linked
The container is arm64 with musl (Alpine), but the compiled module inside node_modules is x86-64 — it was installed on the host or an amd64 runner and copied or bind-mounted in.
Root cause
Native packages ship compiled code in one of two ways. Most publish prebuilt binaries per platform — linux-x64-glibc, linux-arm64-glibc, linux-arm64-musl, darwin-arm64 — and download the matching one at install time. When no prebuilt exists for the exact combination, the installer falls back to compiling from source, which needs a C/C++ toolchain, Python (for node-gyp) and the library's headers; minimal images lack all of these, so the build fails. Prebuilt coverage is best for linux-x64-glibc and weakest for linux-arm64-musl, which is why Alpine on arm64 is the most common failure. The third case is not an install failure at all: node_modules or a virtualenv built on the host (macOS arm64) or on an amd64 runner is copied or bind-mounted into a Linux arm64 container, and the loader rejects binaries for the wrong OS or architecture with invalid ELF header or wrong ELF class.
Resolution
- Install dependencies inside the image, never from the host. Exclude host dependency directories from the build context and keep them off the bind mount:
node_modules
**/node_modules
.venv
__pycache__
Save as .dockerignore, and in Compose shadow the directory with a volume so the host's copy is never used:
services:
api:
build: ./api
volumes:
- ./api:/app
- api-node-modules:/app/node_modules
volumes:
api-node-modules:
- Prefer a glibc base with good prebuilt coverage over Alpine when native modules are involved;
-slimDebian images are a small size increase for far fewer source builds:
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
- When a source build is unavoidable, install the toolchain in a build stage only, so the runtime image stays small:
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
COPY /wheels /wheels
RUN pip install --no-index --find-links=/wheels /wheels/*
- Rebuild dependency volumes after switching architecture, because a named volume created by an amd64 container keeps amd64 binaries:
#!/usr/bin/env bash
set -euo pipefail
docker compose down
docker volume rm "$(basename "$PWD")_api-node-modules" || true
docker compose build --no-cache api
docker compose up -d api
Expected output
$ docker compose run --rm --no-deps api sh -c 'uname -m; file node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node'
aarch64
node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node: ELF 64-bit LSB shared object, ARM aarch64, dynamically linked
$ docker compose logs api | tail -1
api-1 | server listening on :3000
The native module matches the container's architecture, and the service starts. On a Debian-based image, npm ci downloads prebuilt arm64 binaries in seconds instead of compiling.
A quick way to confirm nothing else is lurking is to list every native binary in the dependency tree and check its architecture in one pass: find node_modules -name '*.node' -exec file {} + inside the container should print only ARM aarch64 lines on arm64 and only x86-64 lines on amd64. Any mismatch points to a package that was vendored, cached or copied from elsewhere, and is worth fixing before it causes an intermittent crash in a code path tests rarely hit.
Prevention
Build and test on both architectures in CI with a matrix or
buildx --platform linux/amd64,linux/arm64, so an unsupported native dependency fails the pull request that introduces it.Check new dependencies for prebuilt coverage before adding them:
npm view <pkg> binaryor the package's release assets for Node, and the wheel list on PyPI for Python (look formanylinux_2_28_aarch64ormusllinuxtags).Never bind-mount host dependency directories into Linux containers; lint Compose files for mounts that include
node_modulesor.venvfrom the host.
Platform caveats
Apple Silicon (ARM64): host-side
npm installproducesdarwin-arm64binaries, which never work in Linux containers of any architecture. Keep installs inside containers even when the CPU architecture matches.
macOS (Docker Desktop): with Rosetta enabled, running
--platform linux/amd64images works, but installing inside them produces amd64 binaries that then fail in native arm64 containers sharing the same volume.
WSL2: host-side installs in WSL produce
linux-x64-glibcbinaries, which work in amd64 Debian containers by coincidence and fail on Alpine; the same rule applies — install inside the container.
Rollback
Revert the base image and Compose changes and rebuild; dependency volumes must be recreated whenever the base changes:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- Dockerfile compose.yaml .dockerignore
docker compose down -v
docker compose up -d --build
Frequently Asked Questions
What does invalid ELF header mean?
The loader found a compiled file for a different OS or architecture — typically node_modules installed on macOS or on an amd64 machine and mounted into an arm64 Linux container. Reinstall dependencies inside the container on the target platform.
Why does Alpine cause more native build failures?
Alpine uses musl libc, and many native packages publish prebuilt binaries only for glibc. Without a matching prebuilt, the installer compiles from source, which needs a toolchain Alpine images do not include.
Should we install build tools in the runtime image?
No. Install them in a build stage, produce wheels or compiled modules there, and copy only the results into a slim runtime stage.
How do I check if a package has arm64 binaries?
For Python, look for aarch64 wheels on the package's PyPI files page. For Node, check the package's GitHub release assets or its binary field, and try npm ci in an arm64 container.