#100

Build the JS stages once and install runtime deps per platform

OpenTardisyuanwants to merge intofrom

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: 132

amd64 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 for main'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