Graviton Migration Guide¶
This document explains how to move the Someli web client to AWS Graviton instances (ARM64 CPUs), what could go wrong, and the steps to do it safely.
It's written so that an engineer who hasn't done a CPU-architecture migration before can follow it end-to-end.
TL;DR¶
The codebase is essentially Graviton-ready. You don't need to change any source code. The two real things to do are:
- Build the Docker image on or for ARM64 (one CLI flag).
- Replace the EFS-mounted
node_modules(which currently contains x86_64 binaries) with an ARM64 install — or, better, stop using EFS fornode_modulesaltogether.
Everything else just works because the dependency tree publishes ARM64 binaries for every native package, and the Docker base image is multi-arch.
1. Background: what's actually different about Graviton?¶
AWS Graviton is Amazon's family of ARM-based server CPUs (Graviton 1 / 2 / 3 / 4). Compared to the typical x86_64 (Intel/AMD) instances most people use:
- Same Linux. Same Node.js. Same npm registry.
- Different CPU instruction set —
aarch64/arm64instead ofx86_64. - Significantly cheaper for many workloads.
The instruction set is the only thing that matters here. Anything that ships pre-compiled binary code (native machine instructions) needs an ARM64 build of that binary. Anything that ships only JavaScript/CSS/HTML runs on either architecture without modification.
How to know if a Node.js dependency is "native"¶
A Node.js package is "native" if it contains compiled C / C++ / Rust code that the Node runtime loads as a .node file (a shared library). The most common signs:
- The package has a
binding.gypfile in its source. - The package's
package.jsonhas ascripts.installthat runsnode-gyporprebuild-install. - The package's
node_modulesdirectory contains files ending in.node.
A pure-JavaScript package is just .js (or sometimes .cjs / .mjs) files. No compilation step is needed; it runs on any CPU.
The difference matters because pre-compiled native code is built for one specific CPU architecture. An x86_64 .node file won't load on ARM64 — Node will throw an error like Error: invalid ELF header or wrong architecture.
How modern Node packages handle multi-architecture¶
The pattern most popular packages use today is:
- Publish a "main" package (e.g.
@swc/core). - For each supported platform, publish a separate package (e.g.
@swc/core-linux-x64-gnu,@swc/core-linux-arm64-gnu,@swc/core-darwin-arm64, etc.). - The main package's
package.jsondeclares all of those platform packages inoptionalDependencies.
When you run yarn install (or npm install), the package manager looks at your current OS and CPU, picks the matching optional dependency, downloads it, and silently skips the others. So on x86_64 Linux, you get @swc/core-linux-x64-gnu; on Graviton (ARM64 Linux), you get @swc/core-linux-arm64-gnu. No source change is required — this is built into the package.
The reason this matters for our codebase: every native package we depend on uses this pattern, so a yarn install on Graviton "just works."
2. What we have in this codebase¶
The repo has two Node.js projects:
| Project | Path | Native deps? | When does it run? |
|---|---|---|---|
| Main Nuxt app | / |
None | Build time (Webpack 4 → JS chunks) and runtime (nuxt start serves the SPA) |
| Polotno editor | /polotno-editor/ |
Yes (Parcel 2's toolchain — see below) | Build time only (Parcel produces polotno-bundle.js + polotno-bundle.css at the repo root) |
The crucial property: Polotno's native deps only run during the build step. The artifact it produces (polotno-bundle.js / polotno-bundle.css) is plain JavaScript and CSS — it has no architecture. Once built, it runs in any user's browser, on any OS, regardless of what CPU the build host had.
2.1 Main Nuxt app — clean¶
Verified by:
The main app's dependencies are all pure JavaScript. The "looks suspicious but isn't" list:
sass/sass-loader— uses Dart Sass, which is JS, not the oldnode-sass(which was native).webpack@4— pure JS.aws-sdk(v2) — large, but pure JS. (The Phase 1 recommendation to migrate to v3 modular packages stands for bundle-size reasons, not arch.)bcryptjs— pure JS. (Compare:bcryptis native — but we don't use that one.)passport,express,@sendgrid/mail,stripe(Node SDK) — all pure JS.
So nuxt start will run on ARM64 with no source changes.
2.2 Polotno editor — has native deps, but ARM64 versions are published¶
Inside polotno-editor/node_modules/, the following packages currently have only x86_64 binaries installed (because that's what the dev machine they were installed on was):
| Package | Currently installed | ARM64 version exists? |
|---|---|---|
lightningcss |
lightningcss-linux-x64-{gnu,musl} |
✅ lightningcss-linux-arm64-{gnu,musl} |
@swc/core |
@swc/core-linux-x64-{gnu,musl} |
✅ @swc/core-linux-arm64-{gnu,musl} |
@parcel/rust |
@parcel/rust-linux-x64-{gnu,musl} |
✅ @parcel/rust-linux-arm64-{gnu,musl} |
@parcel/watcher |
@parcel/watcher-linux-x64-{glibc,musl} |
✅ @parcel/watcher-linux-arm64-{glibc,musl} |
lmdb |
@lmdb/lmdb-linux-x64 |
✅ @lmdb/lmdb-linux-arm64 |
msgpackr-extract |
@msgpackr-extract/msgpackr-extract-linux-x64 |
✅ @msgpackr-extract/msgpackr-extract-linux-arm64 |
You can confirm any of these yourself with:
The polotno-editor/yarn.lock already has resolved entries for all the ARM64 alternatives — grep -c "linux-arm64" polotno-editor/yarn.lock returns 48. So when yarn install runs on Graviton, Yarn will pull the correct binaries and skip the x86_64 ones.
2.3 Docker base image — multi-arch ✅¶
Dockerfile uses node:20.18.3-slim for all three stages. Docker images on Docker Hub are typically manifest lists, meaning a single tag points to multiple images, one per platform. node:20.18.3-slim includes both linux/amd64 (x86_64) and linux/arm64/v8 (Graviton).
When you build with docker buildx build --platform=linux/arm64 ..., Docker pulls the ARM64 manifest and runs the build on an emulated or native ARM64 host. No Dockerfile changes needed.
2.4 apt packages installed in the Dockerfile — multi-arch ✅¶
The Dockerfile's stage 3 installs these system packages:
libnss3 libatk-bridge2.0-0 fontconfig libx11-xcb1 libxcomposite1 libxdamage1
libxrandr2 libgbm1 libgtk-3-0 libasound2 libpangocairo-1.0-0 libxshmfence1
libxss1 nginx curl
All of these are part of Debian's standard repositories and are available for arm64 in Debian bookworm (the base of node:20.18.3-slim). No x86_64-only system packages are used.
2.5 EFS-mounted node_modules — the actual blocker¶
This is the one piece that won't "just work."
start.sh (the runtime entrypoint inside the production container) waits for /usr/src/app/node_modules and /usr/src/app/polotno-editor/node_modules to be mounted from EFS:
while [ ! -d "/usr/src/app/node_modules" ] || [ -z "$(ls -A /usr/src/app/node_modules 2>/dev/null)" ]; do
...
sleep 1
done
The current EFS volume was populated by an x86_64 host. It contains:
- The main app's
node_modules— pure JS, so it would technically still load on ARM64 even though it was installed on x86_64. - The Polotno editor's
node_modules— has the x86_64 binaries listed above. These would fail to load if any code tries to use them on ARM64.
In normal runtime operation, the Polotno editor is not built on the production server (it's pre-built during Docker build, and the runtime just serves the resulting bundle). So in theory, the existing EFS volume might "happen to work" for the main runtime path.
But: don't ship that. It's brittle. One accidental rebuild, one new dependency that turns out to be native, one operations script that runs yarn install for any reason, and you get an opaque crash with no obvious cause.
The clean fix is one of:
- Stop using EFS for
node_modules(recommended). Bakenode_modulesinto the Docker image as a normal Docker layer. Multi-arch Docker handles everything for you. - Repopulate EFS from a Graviton host. Spin up a temporary Graviton EC2, run
yarn installfor both projects, copy the result into a fresh EFS volume, and point the Graviton service at that volume.
3. Migration steps¶
Step 1 — Verify the build works on ARM64¶
On any machine that has Docker installed (your laptop is fine — Docker on Mac/Linux supports cross-arch via emulation):
# Enable buildx if you haven't already
docker buildx create --use --name multiarch-builder
# Build for ARM64
docker buildx build --platform=linux/arm64 -t someli-platform:arm64 --load .
The first time this runs, Docker will pull the ARM64 base image and emulate ARM64 for the install/build. It'll be slower than a native build (emulation), but it should succeed.
If the build succeeds, most of the work is done — the Polotno bundle was successfully produced from an ARM64 toolchain and the main Nuxt app was built.
Step 2 — Decide on the EFS strategy¶
Pick one:
Option A — Bake node_modules into the image (recommended)¶
Modify the Dockerfile to not delete node_modules at the end of each build stage. Currently the Dockerfile does:
# Cleanup — node_modules will be mounted via EFS
RUN rm -rf node_modules
RUN rm -rf polotno-editor/node_modules
Remove those rm -rf lines (they exist twice — once after polotno-editor build, once after the main build). Then update start.sh to remove the EFS-wait loop. The node_modules will be in the image layer.
This is what the audit's Risk WC-RISK-15 already recommended (see web-client/enterprise-readiness.md Phase 1). Graviton migration is a natural reason to do it now.
Trade-off: image size goes up by ~1.5 GB. But:
- Image layers are gzip-compressed in transit.
- The container starts faster (no 60-second EFS wait).
- The deploy is reproducible — no risk of EFS being out of sync with the rest of the image.
Option B — Keep EFS, but repopulate it from a Graviton host¶
If for operational reasons you can't change the EFS architecture right now:
# 1. Provision a temporary Graviton EC2 (e.g. t4g.medium)
# 2. Mount a fresh EFS volume to it
# 3. Clone the repo and run yarn install for both projects:
git clone <repo-url>
cd someli-platform
yarn install --frozen-lockfile
cd polotno-editor
yarn install --frozen-lockfile
cd ..
# 4. Copy node_modules to the EFS mount:
cp -a node_modules /mnt/efs/someli-platform-arm64/node_modules
cp -a polotno-editor/node_modules /mnt/efs/someli-platform-arm64/polotno-editor-node_modules
# 5. Point the Graviton ECS task / Lightsail container at this new EFS volume.
This works, but it's harder to keep the EFS in sync when dependencies change. Every yarn add requires re-running this process. Option A is cleaner long-term.
Step 3 — Update CI¶
If you use the GitHub Actions workflows in .github/workflows/:
- The simplest change: add
--platform=linux/arm64to the existingdocker buildx buildinvocation. The workflow runs onubuntu-latest(x86_64), which uses QEMU emulation — slower but it works. - The fast option: switch the runner to GitHub's ARM64 hosted runner. Update
runs-on:toubuntu-24.04-arm. The build then runs natively on ARM64 — significantly faster.
If you use Jenkins (Jenkinsfile):
- The Jenkins setup SSHes into the deploy host and runs
yarn install && yarn builddirectly there. Move the deploy host to a Graviton EC2 (or change the Jenkins job to deploy to a Graviton host), and the build happens on ARM64 natively.
Step 4 — Smoke test in the Graviton environment¶
After the new container is running on Graviton, verify by hand:
- Hit the homepage —
curl https://<host>/should return HTML. - Log in — go through the email/password flow. Cookies should set normally.
- Open the content planner — confirm the calendar and posts load.
- Open a post editor (any of
posteditor,cuseditor,usereditor, etc.) — confirm the Polotno canvas mounts. This is the most important check, because it's the path that exercises the polotno-bundle artifact. If the canvas appears blank, the bundle didn't load — investigate. - Save a post — confirm the round-trip to the backend works.
Step 5 — Verify no native binaries snuck in for x86_64¶
After the install completes on Graviton, run:
find node_modules polotno-editor/node_modules -name '*.node' | xargs file 2>/dev/null | grep -i x86_64
Should return empty. If anything appears, that's a package whose optionalDependencies didn't include an ARM64 entry — investigate that specific package.
4. Common gotchas¶
"I built locally and it worked, but the Graviton container fails to start"¶
Most likely cause: you copied your locally-built node_modules (containing x86_64 binaries) into the image instead of doing a fresh yarn install on the ARM64 builder.
Fix: ensure the Dockerfile does the yarn install step inside the multi-arch build, not on your laptop and then COPY node_modules. The current Dockerfile already does this correctly; just confirm any custom build script isn't shortcutting around it.
"Build is very slow"¶
If you're cross-building from x86_64 to ARM64 (e.g. on your laptop), Docker uses QEMU to emulate the ARM64 CPU. Emulated builds are 2–5× slower than native.
Fix: use a native ARM64 builder. Either:
- A Graviton EC2 instance for CI builds.
- GitHub's ARM hosted runners (
ubuntu-24.04-arm). - An ARM Mac (Apple Silicon) for local development.
"I see weird EBADPLATFORM errors during install"¶
This means a non-optional native dependency demands a specific platform. None of our current deps do this, but if it appears, it's a package added since this doc was written. Check that package's optionalDependencies — if it lists ARM64, just retry the install. If it doesn't list ARM64, that package needs to be replaced.
"yarn says some optional dependencies failed to install"¶
Yarn 1 always prints warnings about optional deps that don't match the current platform. Lines like:
warning Pattern ["@swc/core-darwin-arm64@..."] is trying to unpack in the same destination... ignored
warning ... skipping optional dependency "@swc/core-linux-x64-gnu@..." because it does not match the platform
These are expected and harmless. They're Yarn telling you it correctly skipped the wrong-arch entries.
"I'm on Apple Silicon (Mac M1/M2/M3) — does this affect anything?"¶
Macs use ARM64 natively, but Apple Silicon's ARM64 (darwin-arm64) is different from Linux ARM64 (linux-arm64-gnu). For local dev, Yarn will install @swc/core-darwin-arm64, which works on your Mac. For Docker, set --platform=linux/arm64 to force the Linux ARM64 binaries inside the container. The two don't conflict.
"The host instance I picked has fewer vCPUs and the build is slow"¶
Graviton instances are typically slightly cheaper per vCPU than x86_64, but a t4g.small is still a small instance. For build hosts, prefer at least c7g.large or similar. For runtime, t4g.medium is usually plenty for this app.
5. Verification checklist¶
Before declaring the migration complete:
-
docker buildx build --platform=linux/arm64 -t someli-platform:arm64 .succeeds. - Container runs on a Graviton host (Lightsail / ECS / EC2) and reaches a healthy state.
- HTTP
GET /returns the SPA shell with200. - Login works end-to-end.
- Content planner loads and shows posts.
- Post editor mounts the Polotno canvas.
- Saving a post works.
- No x86_64
.nodefiles in eithernode_modules: - EFS strategy decided and implemented (option A or option B).
- CI builds for ARM64 (or builds multi-arch images).
- If you went with option A, the audit's Risk WC-RISK-15 (in
web-client/enterprise-readiness.md) is closed.
6. Where the evidence comes from¶
If you want to verify any of this yourself:
| Claim | How to verify |
|---|---|
| Main app has no native deps | find node_modules -name '*.node' returns empty |
| Polotno's native packages have ARM64 alternatives | jq '.optionalDependencies' polotno-editor/node_modules/<package>/package.json shows linux-arm64-* entries |
| Yarn knows about ARM64 alternatives | grep -c "linux-arm64" polotno-editor/yarn.lock returns 48 |
| Docker base image is multi-arch | docker manifest inspect node:20.18.3-slim lists linux/amd64 and linux/arm64/v8 |
| Debian apt packages are multi-arch | apt-cache madison libnss3 (etc.) on a Debian system shows arm64 entries |
7. Cross-references¶
- Risk WC-RISK-15 in
web-client/enterprise-readiness.md— the audit-side story for "stop EFS-mountingnode_modules," which is the right long-term fix that aligns with this migration. 07-build-and-deploy.md— the existing developer-side description of the Dockerfile + EFS architecture, including thestart.shboot semantics.web-client/build-and-deploy.md— the audit-side build & deploy doc, where the EFS fragility is also flagged independently.