DEV Community

Cover image for Multi-Architecture Docker Builds for Node.js: From Apple Silicon to AWS Graviton
Raju Dandigam
Raju Dandigam

Posted on

Multi-Architecture Docker Builds for Node.js: From Apple Silicon to AWS Graviton

Introduction

A few years ago, many teams could ignore CPU architecture when building Docker images. Most development machines were x86, most CI runners were x86, and most production servers were x86. If the image worked in CI, it probably worked in production.

That world has changed.

Many developers now use Apple Silicon laptops, which run on ARM64. AWS Graviton instances use ARM-based processors and are widely used for cost and performance optimization. Edge devices and small compute environments often use ARM as well. At the same time, many CI pipelines still run on AMD64 Linux runners.

This creates a practical problem for Node.js teams. An image built for one architecture may not run efficiently on another. It may run through emulation, but that can be slower and less predictable. It may fail completely if the image contains native binaries for the wrong architecture.

Docker Buildx solves this by allowing teams to build multi-platform images from a single Dockerfile. Docker's documentation describes a multi-platform build as a single build invocation that targets multiple operating system or CPU architecture combinations, such as linux/amd64 and linux/arm64.

For TypeScript and Node.js applications, this is especially useful when the same app needs to work across Apple Silicon development machines, Linux CI, and ARM64 production environments such as AWS Graviton.

The Real-World Problem

Imagine a team building a TypeScript API. Developers use M-series MacBooks. GitHub Actions builds the image on Ubuntu runners. Production runs on AWS ECS, and the team wants to move some workloads to Graviton for better price performance.

At first, the Dockerfile looks fine.

FROM node:22-slim

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

This may work until the team introduces a native dependency such as sharp, canvas, sqlite3, bcrypt, or a browser automation dependency. These packages may use native binaries or system libraries. If the wrong architecture is built, cached, or pulled, the application may fail in confusing ways.

The issue is not TypeScript itself. TypeScript compiles to JavaScript, which is mostly architecture-independent. The issue is the runtime environment around it: Node.js binaries, native npm modules, base image packages, browser dependencies, and platform-specific libraries.

How Multi-Architecture Images Work

A multi-architecture image is usually published as a manifest list, also called an image index. The tag points to multiple platform-specific images. Docker chooses the correct one when the image is pulled.

Docker's manifest CLI documentation explains that a manifest list contains one or more image names and can be used like an image name in docker pull or docker run.

Here is the idea.

The developer does not need separate image names such as my-app-amd64 and my-app-arm64. They pull the same tag, and Docker selects the matching image for the machine.

Setting Up Docker Buildx

Buildx is Docker's extended build tool powered by BuildKit. It is included with Docker Desktop and modern Docker installations.

Check that it is available.

docker buildx version
Enter fullscreen mode Exit fullscreen mode

Create and use a builder that supports multi-platform builds.

docker buildx create --name multiarch-builder --driver docker-container --bootstrap
docker buildx use multiarch-builder
Enter fullscreen mode Exit fullscreen mode

Inspect the builder.

docker buildx inspect --bootstrap
Enter fullscreen mode Exit fullscreen mode

You should see supported platforms such as linux/amd64 and linux/arm64.

A Multi-Architecture Dockerfile for TypeScript

For a straightforward TypeScript API, the Dockerfile does not need to be complicated.

FROM node:22-slim AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src

RUN npm run build

FROM node:22-slim AS runtime

WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=build /app/dist ./dist

USER node

EXPOSE 3000

CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

This works well across architectures when your dependencies support the target platforms. The official Node images support common platforms including AMD64 and ARM64, so the base image is not usually the hard part.

Build and push a multi-platform image like this.

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t yourname/node-agent:latest \
  --push \
  .
Enter fullscreen mode Exit fullscreen mode

The --platform flag tells Docker which architectures to build. The --push flag pushes the multi-platform image to a registry. Docker's multi-platform GitHub Actions documentation notes that the default Docker setup for GitHub Actions runners supports building and pushing multi-platform images to registries.

What About Native npm Modules?

Native modules are where multi-architecture builds become more interesting.

Packages such as sharp, canvas, bcrypt, and sqlite3 may depend on native code or system libraries. During a multi-platform build, each platform should install dependencies for that platform. That is what you want, but it means your Dockerfile must provide any required build or runtime packages.

For example, if your AI app processes images with sharp, you may need system dependencies depending on your base image and package version.

FROM node:22-slim AS build

WORKDIR /app

RUN apt-get update \
  && apt-get install -y --no-install-recommends python3 make g++ \
  && rm -rf /var/lib/apt/lists/*

COPY package*.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src

RUN npm run build

FROM node:22-slim AS runtime

WORKDIR /app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --from=build /app/dist ./dist

USER node

CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

This is not something every project needs. Add build tools only when your dependencies require them. The important lesson is to test both platforms, not assume that a successful AMD64 build proves ARM64 is safe.

Playwright and Browser Dependencies

Playwright adds another practical wrinkle. Browser dependencies can be large, platform-specific, and sensitive to the base image.

If Playwright is used only for testing, keep it out of your production API image. Use the official Playwright image for tests and keep the app image small.

services:
  app:
    image: yourname/node-agent:latest
    ports:
      - "3000:3000"

  tests:
    image: mcr.microsoft.com/playwright:v1.56.1-noble
    working_dir: /app
    volumes:
      - ./:/app
    command: sh -c "npm ci && npx playwright test"
Enter fullscreen mode Exit fullscreen mode

If your agent truly needs browser automation at runtime, consider a separate browser worker image. Do not force every API container to carry browser dependencies if only one workflow needs them.

That architecture is usually cleaner.

GitHub Actions Build

A CI workflow can build and push both AMD64 and ARM64 images.

name: Build Multi-Arch Image

on:
  push:
    branches:
      - main

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: yourname/node-agent:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
Enter fullscreen mode Exit fullscreen mode

The Docker setup-buildx action creates and boots a builder for use with Buildx and Docker's build-push action, and its documentation notes that the default docker-container driver supports multi-platform images and cache export through a BuildKit container.

The Docker build-push action supports Buildx features including multi-platform builds, secrets, and remote cache.

Why AWS Graviton Makes This Worth It

Multi-architecture builds are not only about developer convenience. They can also unlock cloud cost and performance options.

AWS says Graviton-based instances can deliver up to 40% better price performance compared with comparable current-generation x86-based instances, depending on workload and instance family.

That does not mean every Node.js service automatically saves 40%. You still need to benchmark. But multi-architecture images make it possible to test the same application on x86 and ARM without maintaining two separate image pipelines.

For teams running many Node.js APIs, workers, or agent services, that flexibility can matter. Even a modest percentage improvement becomes meaningful at scale.

Testing Both Architectures

After building the image, test both platforms.

docker run --rm --platform linux/amd64 yourname/node-agent:latest
docker run --rm --platform linux/arm64 yourname/node-agent:latest
Enter fullscreen mode Exit fullscreen mode

On a host that does not match the requested platform, Docker may use emulation. That is useful for basic validation, but it is not a substitute for testing on real ARM64 infrastructure if performance matters.

For production readiness, run at least one deployment test on the actual target architecture. For AWS, that may mean ECS tasks, EKS nodes, or EC2 instances backed by Graviton.

Common Mistakes

The first mistake is assuming JavaScript means architecture does not matter. Pure JavaScript is portable, but Node.js apps often include native packages, system libraries, and browser tooling.

The second mistake is using base images that do not support all target platforms. Official images such as Node generally support common platforms, but third-party images may not.

The third mistake is building multi-architecture images without testing the ARM64 path. A manifest can exist while one platform image still has a runtime bug.

The fourth mistake is treating multi-architecture builds as free. They add build time and CI complexity. Use caching, and only support platforms you actually need.

When Multi-Architecture Builds Are Worth It

Multi-architecture builds are worth it when your developers use Apple Silicon, your production platform includes ARM64, you want to evaluate AWS Graviton, or you deploy to edge devices.

They are less urgent when your entire development, CI, and production stack is AMD64 and you have no plan to change. In that case, the added complexity may not be justified yet.

For AI and agent workloads, this decision depends on the runtime. A simple Node.js orchestration service may run well on ARM64. A workload that depends heavily on specific native libraries, browser automation, or local model inference needs more careful testing.

Conclusion

Multi-architecture Docker builds are a practical way to make Node.js applications work across the hardware landscape developers actually use today.

Apple Silicon changed local development. AWS Graviton changed cloud cost and performance discussions. Edge and ARM devices continue to grow. Docker Buildx connects these worlds by letting one image tag point to the right platform-specific image.

For TypeScript apps, the basic setup is straightforward. Use a portable Dockerfile, build with docker buildx build --platform linux/amd64,linux/arm64, push to a registry, and let Docker select the correct image at pull time.

The hard parts are the real-world details: native npm modules, browser dependencies, image caching, and testing both architectures before trusting production. Get those right, and multi-architecture builds become more than a Docker feature. They become a path to better developer experience, more deployment flexibility, and potentially lower cloud costs.

Top comments (0)