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

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

Introduction

The Real-World Problem

How Multi-Architecture Images Work

Setting Up Docker Buildx

A Multi-Architecture Dockerfile for TypeScript

What About Native npm Modules?

Playwright and Browser Dependencies

GitHub Actions Build

Why AWS Graviton Makes This Worth It

Testing Both Architectures

Common Mistakes

When Multi-Architecture Builds Are Worth It

Conclusion 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. 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. 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. 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. 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. 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. Create and use a builder that supports multi-platform builds. You should see supported platforms such as linux/amd64 and linux/arm64. For a straightforward TypeScript API, the Dockerfile does not need to be complicated. 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. 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. 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. 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 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. 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. A CI workflow can build and push both AMD64 and ARM64 images. 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. 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. After building the image, test both platforms. 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. 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. 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

FROM node:22-slim WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build CMD ["node", "dist/index.js"] FROM node:22-slim WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build CMD ["node", "dist/index.js"] FROM node:22-slim WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build CMD ["node", "dist/index.js"] docker buildx version docker buildx version docker buildx version docker buildx create --name multiarch-builder --driver docker-container --bootstrap docker buildx use multiarch-builder docker buildx create --name multiarch-builder --driver docker-container --bootstrap docker buildx use multiarch-builder docker buildx create --name multiarch-builder --driver docker-container --bootstrap docker buildx use multiarch-builder docker buildx inspect --bootstrap docker buildx inspect --bootstrap docker buildx inspect --bootstrap 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"] 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"] 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"] docker buildx build \ --platform linux/amd64,linux/arm64 \ -t yourname/node-agent:latest \ --push \ . docker buildx build \ --platform linux/amd64,linux/arm64 \ -t yourname/node-agent:latest \ --push \ . docker buildx build \ --platform linux/amd64,linux/arm64 \ -t yourname/node-agent:latest \ --push \ . 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"] 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"] 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"] 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" 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" 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" 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 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 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 docker run --rm --platform linux/amd64 yourname/node-agent:latest docker run --rm --platform linux/arm64 yourname/node-agent:latest docker run --rm --platform linux/amd64 yourname/node-agent:latest docker run --rm --platform linux/arm64 yourname/node-agent:latest docker run --rm --platform linux/amd64 yourname/node-agent:latest docker run --rm --platform linux/arm64 yourname/node-agent:latest