Skip to content

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:

  1. Build the Docker image on or for ARM64 (one CLI flag).
  2. Replace the EFS-mounted node_modules (which currently contains x86_64 binaries) with an ARM64 install — or, better, stop using EFS for node_modules altogether.

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 setaarch64 / arm64 instead of x86_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.gyp file in its source.
  • The package's package.json has a scripts.install that runs node-gyp or prebuild-install.
  • The package's node_modules directory 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:

  1. Publish a "main" package (e.g. @swc/core).
  2. 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.).
  3. The main package's package.json declares all of those platform packages in optionalDependencies.

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:

find node_modules -name '*.node'
# (empty)
find node_modules -name 'binding.gyp'
# (empty)

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 old node-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: bcrypt is 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:

jq '.optionalDependencies' polotno-editor/node_modules/<package>/package.json

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_modulesthe 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_modulespure 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:

  1. Stop using EFS for node_modules (recommended). Bake node_modules into the Docker image as a normal Docker layer. Multi-arch Docker handles everything for you.
  2. Repopulate EFS from a Graviton host. Spin up a temporary Graviton EC2, run yarn install for 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:

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/arm64 to the existing docker buildx build invocation. The workflow runs on ubuntu-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: to ubuntu-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 build directly 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:

  1. Hit the homepagecurl https://<host>/ should return HTML.
  2. Log in — go through the email/password flow. Cookies should set normally.
  3. Open the content planner — confirm the calendar and posts load.
  4. 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.
  5. 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 with 200.
  • Login works end-to-end.
  • Content planner loads and shows posts.
  • Post editor mounts the Polotno canvas.
  • Saving a post works.
  • No x86_64 .node files in either node_modules:
    find node_modules polotno-editor/node_modules -name '*.node' | xargs file 2>/dev/null | grep x86_64
    # (empty)
    
  • 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-mounting node_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 the start.sh boot semantics.
  • web-client/build-and-deploy.md — the audit-side build & deploy doc, where the EFS fragility is also flagged independently.