What this changes
The frontend and backend build stages move to $BUILDPLATFORM, and the runtime layer installs its own production deps instead of copying node_modules out of the build stage.
Why
Both build stages are currently unpinned, so a two-platform buildx run executes each of them twice — once per target — and the arm64 pass runs npm ci and vite build under QEMU. Those stages produce only JS, which is platform-independent, so building them once on the native architecture is enough.
Pinning them means the runtime layer can no longer copy the build stage's node_modules, since that would then be the build machine's architecture: yauzl-promise → @node-rs/crc32 ships prebuilt napi binaries per arch. It installs production deps itself instead, which also stops the copy from carrying devDependencies into the image — 232 MB → 198 MB for the same tree, measured locally on arm64.
To be clear about what this is not: the current layout is correct for native binaries, precisely because the unpinned stages are built per target platform. This is a build-time and image-size change, not a fix for a broken image.
The emulator pin
bufferutil (via wisp-js) has no linux-arm64-musl prebuild and compiles from source, so the runtime layer adds and removes python3 make g++ inside one RUN and never leaves the toolchain behind. After the change above, that compile is the only emulated step left for arm64 — and binfmt's floating latest tag ships an emulator that kills alpine's gcc 15.2:
qemu: uncaught target signal 4 (Illegal instruction) - core dumped
ERROR: process "/bin/sh -c apk add ... && npm ci --omit=dev ..." did not complete successfully: exit code: 132amd64 completed through the same failed build, and the same Dockerfile built successfully a month earlier, so what changed is the emulator underneath. Pinned to tonistiigi/binfmt:qemu-v8.1.5 rather than dropping the arm64 image.
Worth knowing even if you take nothing else here: this will hit main on its next multi-platform build.
Workflow
Pushes to fix/** and feat/** build too, plus a workflow_dispatch, and each build is tagged with its branch name, so a branch can be tested as an image before it merges. Drop this part if you would rather keep the trigger to main and tags.
Testing
docker build .succeeds locally on arm64; image 198 MB against 232 MB formain's Dockerfile on the same tree- Both published platforms build in CI with the pin, 1m49s for the two-platform run
🤖 Generated with Claude Code