Node Native Modules on ARM64 Runners
A .node addon compiled for x64 fails on ARM64 with invalid ELF header. Install on the aarch64 machine, key caches on runner.arch, rebuild what has no prebuild.
Last verified:
A native Node module is an ELF shared object holding machine code for one CPU architecture, so a .node file produced on an x64 runner fails on an ARM64 runner with invalid ELF header the first time something requires it. The fix is to run the install on the aarch64 machine itself, carry ${{ runner.arch }} through every cache key, and rebuild the small set of packages that publish no arm64 prebuilt binary.
This guide covers the error signatures that identify an architecture mismatch, the three ways an x64 addon reaches an ARM64 job, a workflow with an architecture-aware cache key and a rebuild step, and a time model of prebuilt downloads against source compilation at each ARM64 size.
Diagnosis
Read the message before changing anything, because three different failures produce similar-looking logs and only one of them is about architecture.
| Message in the log | What produced it |
|---|---|
.../build/Release/better_sqlite3.node: invalid ELF header | An x86-64 addon loaded by an aarch64 Node process. The header says the wrong machine type and the loader stops there. |
Cannot find module '@rollup/rollup-linux-arm64-gnu' | node_modules arrived from an install that ran somewhere else, so only the optional package for that other platform is present. |
Could not load the "sharp" module using the linux-arm64 runtime | Same shape as the row above, reported by a package that resolves its own platform binary at runtime. |
prebuild-install warn install No prebuilt binaries found (target=22.x runtime=node arch=arm64 libc= platform=linux) followed by gyp info spawn make | Working as intended. No arm64 asset was published, so the package is compiling from source. This is a duration problem rather than a correctness problem. |
was compiled against a different Node.js version using NODE_MODULE_VERSION 115. This version requires 127 | A Node major version change. The architecture is fine and the same addon rebuilt on the current Node version will load. |
Error relocating ...: symbol not found inside an Alpine container | A glibc build of the addon on a musl system, which needs the linux-arm64-musl variant instead. |
Confirm the machine and the artifacts in one step. uname -m prints aarch64 and node -p "process.arch" prints arm64 on a real ARM64 runner, and file reports the machine type baked into each addon:
- name: Report architecture and addons
run: |
uname -m
node -p "process.arch"
find node_modules -name '*.node' -exec file {} + | sort -uEvery line of that file output should read ELF 64-bit LSB shared object, ARM aarch64. Any x86-64 line is the module that will throw.
Three routes put an x64 addon on an aarch64 machine. The most common is a cache entry whose key omits the architecture, because ${{ runner.os }} resolves to Linux on both legs of a matrix and a lockfile hash is identical on both, so the two legs address the same entry (GitHub contexts reference). The second is node_modules moved between jobs as a workflow artifact, which carries the compiled tree along with it. The third is an install that never ran on aarch64 at all, such as a container image built for linux/amd64 and started on the ARM64 runner.
The reason a fresh install fixes all three is that npm resolves optionalDependencies against the os and cpu of the machine performing the install, and prebuild-install and node-pre-gyp request an asset named for the running platform, architecture, and ABI. Both decisions are made at install time on the install machine, so an install that runs on aarch64 produces an aarch64 tree.
Fix
Give each architecture its own install and never move the resulting tree.
- Run the ARM64 leg on a native ARM64 runner. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and the ARM64 sizes attach through the
runs-onlabel listed in the cloud runners documentation:warp-ubuntu-latest-arm64-2xthroughwarp-ubuntu-latest-arm64-32x. The full label list with preinstalled tooling is on the Linux ARM64 runner page. - Cache the package manager download directory rather than
node_modules. Registry tarballs are architecture independent and safe to reuse; the installed tree is not. - Put
${{ runner.arch }}in the primary key and in everyrestore-keysprefix. A prefix that drops the segment reopens the hole on the first run where the exact key misses. - Add a rebuild step for the packages with no arm64 prebuild, and pin it to the packages you have identified rather than rebuilding the whole tree.
- Fail the job when a foreign addon appears, so the mismatch shows up as a build failure instead of a runtime crash in a later step.
For step 4, npm rebuild <package> reruns node-gyp against the current Node version and architecture, and node-gyp reads JOBS to set the make parallelism, so match it to the vCPU count of the label (node-gyp). node-gyp needs python3, make, and a C++ compiler on the PATH; confirm they are present when the job runs inside a slim container image.
One porting detail applies to the Ubuntu 24.04 ARM64 image: its work directory is /runner/_work, so any script with a hardcoded runner path should read $GITHUB_WORKSPACE instead.
Configuration
The matrix below runs both architectures, keys the download cache on runner.arch, rebuilds the two packages that ship no arm64 asset, and verifies the tree before the tests run. The cache action is the drop-in replacement documented in the caching reference.
name: test
on:
pull_request:
jobs:
test:
strategy:
fail-fast: false
matrix:
include:
- runner: warp-ubuntu-latest-x64-4x
jobs: 4
- runner: warp-ubuntu-latest-arm64-4x
jobs: 4
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: WarpBuilds/cache@v1
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ runner.arch }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-${{ runner.arch }}-node22-
npm-${{ runner.os }}-${{ runner.arch }}-
- run: npm ci
- name: Rebuild addons without arm64 prebuilds
if: runner.arch == 'ARM64'
env:
npm_config_build_from_source: "true"
JOBS: ${{ matrix.jobs }}
run: npm rebuild better-sqlite3 node-re2
- name: Fail on foreign addons
run: |
expected=$([ "$(uname -m)" = "aarch64" ] && echo aarch64 || echo x86-64)
bad=$(find node_modules -name '*.node' -exec file {} + | grep -v "$expected" || true)
if [ -n "$bad" ]; then
echo "$bad"
exit 1
fi
- run: npm testThree details in that file do the work. path: ~/.npm keeps the compiled tree out of the cache entirely, so the worst case of a bad restore is a slower install rather than a broken one. The runner.arch segment appears in the primary key and in both fallback prefixes. And the verification step compares against uname -m, so the same step guards both legs without a per-leg condition.
npm ci deletes node_modules before installing, which is what makes the per-leg install trustworthy. npm install on top of a restored tree leaves whatever was already there.
Cost or Time Model
Compiling from source moves work from the network onto the CPU, so the two paths respond to runner size in opposite directions. Substitute your own numbers; the assumptions below describe one dependency tree.
- Six packages with no arm64 prebuild, totalling 24 CPU-minutes of compilation.
- Parallel efficiency of 80 percent across the available vCPU, with
JOBSset to the vCPU count. - 1.2 minutes for checkout, Node setup, and an
npm cithat finds every prebuilt binary, on every size. - 800 installs per month.
- Linux ARM64 rates from the pricing page, billed per minute, checked on 2026-08-13.
| Label | vCPU | Rate | Prebuilt path | Source path | Monthly, prebuilt | Monthly, source |
|---|---|---|---|---|---|---|
warp-ubuntu-latest-arm64-2x | 2 | $0.003 | 1.2 min | 16.2 min | $2.88 | $38.88 |
warp-ubuntu-latest-arm64-4x | 4 | $0.006 | 1.2 min | 8.7 min | $5.76 | $41.76 |
warp-ubuntu-latest-arm64-8x | 8 | $0.012 | 1.2 min | 4.95 min | $11.52 | $47.52 |
warp-ubuntu-latest-arm64-16x | 16 | $0.024 | 1.2 min | 3.08 min | $23.04 | $59.04 |
warp-ubuntu-latest-arm64-32x | 32 | $0.048 | 1.2 min | 2.14 min | $46.08 | $82.08 |
Read the last two columns together. On the prebuilt path the install is network bound, so a larger label buys nothing and costs more per minute. On the source path a larger label returns wall clock time and raises the monthly bill anyway, because the rate climbs faster than the compile time falls: the 32 vCPU row finishes 14 minutes sooner per run than the 2 vCPU row and costs $43.20 more per month. The cheapest move is removing packages from the source column, either by upgrading to a version that publishes an arm64 asset or by caching the compiled output under an architecture-aware key.
For a list-price baseline at the middle size, warp-ubuntu-latest-arm64-4x (4 vCPU, 16 GB) costs $0.006 per minute against $0.008 per minute for the 4-core Linux ARM64 larger runner (4 vCPU, 16 GB): 25 percent lower list price. GitHub list prices checked on 2026-08-13 in the GitHub Actions billing reference. The 6,960 monthly minutes of the source path in that row therefore cost $41.76 here against $55.68 on the GitHub-hosted equivalent.
Cache storage is $0.20 per GB-month and each cache write or restore is $0.0001, from the caching reference and the pricing page, checked on 2026-08-13. Holding a 1 GB ~/.npm entry for each architecture is $0.40 a month, against the $41.76 the 4 vCPU source path costs at 800 installs.
The utilization metrics behind the JOBS setting come from CI observability.
FAQ
Why does a native Node module built on an x64 runner fail on an ARM64 runner?
A .node file is an ELF shared object holding machine code for one architecture. An aarch64 Node process that dlopens an x86-64 addon rejects it at the header, which surfaces as invalid ELF header on the first require. The module has to be fetched or compiled for aarch64 by an install that runs on the aarch64 machine.
Do I have to rebuild every dependency when I add an ARM64 leg?
No. Only packages that ship a compiled addon are involved, and most of the widely used ones publish linux-arm64 prebuilt binaries that prebuild-install or node-pre-gyp downloads during a normal install. Source compilation applies to the remainder, which is usually a handful of packages, and node-gyp needs python3, make, and a C++ compiler on the PATH for those.
Can the x64 and ARM64 legs of a matrix share one cache entry?
They can share the package manager download directory and nothing else. Registry tarballs are architecture independent, while node_modules holds resolved optional packages and compiled .node files that belong to the architecture that installed them. Cache ~/.npm with ${{ runner.arch }} in the key and let each leg run its own install, as covered in do GitHub Actions caches work across architectures.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.