TypeScript

Fix slow Docker ARM64 multi-platform builds on GitLab CI

Stop waiting on an emulated arm64 build to time out the job: three fixes, in the order that actually removes the wait instead of just hiding it.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

A pipeline that builds linux/amd64,linux/arm64 with docker buildx build --push finishes the amd64 half quickly, then the arm64 half runs for ten times as long and the GitLab job hits its timeout. The question that put a number on it is a Stack Overflow question from a reader hitting exactly this on a Node.js build.

TL;DR#

The runner is amd64 hardware, so --platform linux/arm64 builds under QEMU emulation, which is slow for anything CPU-heavy (yarn build, npm ci with native addons, tsc). There are three ways to actually remove the wait, not just hide it:

  1. Pin the build stage to FROM --platform=$BUILDPLATFORM so it runs natively and only the final, architecture-specific stage pays for the target platform.
  2. Split the job into one per architecture and send the arm64 one to a runner that is actually arm64 hardware, then merge the two images into one manifest.
  3. If nothing above fits, keep emulation but make the emulated step itself smaller (fewer things compiled under QEMU).

The symptom#

yaml
image: docker:20
 
variables:
    PROJECT_NAME: "project"
    BRANCH_NAME: "main"
    IMAGE_NAME: "$PROJECT_NAME:$CI_COMMIT_TAG"
 
services:
    - docker:20-dind
 
build_image:
    script:
      - docker login $CI_REGISTRY -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
      - docker context create builder-context
      - docker buildx create --name builderx --driver docker-container --use builder-context
      - docker buildx build --tag $CI_REGISTRY/mygroup/$PROJECT_NAME/$IMAGE_NAME --push --platform=linux/arm64/v8,linux/amd64 .

against a Dockerfile that builds a Node app and copies the output into nginx:

dockerfile
FROM node:18-bullseye AS node-build
ENV NODE_ENV=production
WORKDIR /usr/src/app
COPY . /usr/src/app/
RUN yarn install --silent --production=true --frozen-lockfile
RUN yarn build --silent
 
FROM nginx:stable-alpine
COPY --from=node-build /usr/src/app/dist /usr/share/nginx/html

amd64 finishes in the usual time. arm64 takes close to ten times as long on the same job, and times out.

Why it happens#

docker buildx build --platform linux/amd64,linux/arm64 can produce a multi-platform image in three different ways, and the default on a single amd64 runner is the slowest one. Docker's multi-platform build documentation names them: QEMU emulation, multiple native builder nodes, or cross-compilation with BUILDPLATFORM/TARGETPLATFORM.

QEMU emulation requires no Dockerfile changes, which is why docker buildx create --driver docker-container plus --platform=... "just works" the first time — and why it is also the slowest option, because every RUN instruction in the arm64 half of the build is translated instruction-by-instruction on amd64 silicon instead of running natively. Docker's own documentation warns that this emulation is much slower than a native build for exactly this kind of compute-heavy step, naming compilation and (de)compression as examples — see the QEMU section of the multi-platform build page. yarn install and yarn build are CPU-bound (dependency resolution, bundler work), so they are exactly the kind of step QEMU punishes the most.

Fix 1: stop emulating the stage that does not need an architecture#

The Node build stage in the question's Dockerfile produces a dist/ folder of static JavaScript. That output is not CPU-architecture-specific — only the final nginx:stable-alpine stage that serves it is. Docker's build arguments documentation defines BUILDPLATFORM (the platform the builder is actually running on) and TARGETPLATFORM (the platform passed to --platform) as automatic build arguments available in the Dockerfile's global scope. Pinning the build stage to $BUILDPLATFORM keeps it on native hardware regardless of how many --platform targets the build runs for:

dockerfile
# syntax=docker/dockerfile:1
 
# Runs on the builder's own architecture: no QEMU, even when --platform=linux/amd64,linux/arm64
FROM --platform=$BUILDPLATFORM node:18-bullseye AS node-build
ENV NODE_ENV=production
WORKDIR /usr/src/app
# copy the lockfile first so dependency install is cached independently of source changes
COPY package.json yarn.lock ./
RUN yarn install --silent --production=true --frozen-lockfile
COPY . /usr/src/app/
RUN yarn build --silent
 
# This stage does not need a $TARGETPLATFORM pin: it already targets whatever
# platform buildx is currently producing a manifest entry for
FROM nginx:stable-alpine
COPY --from=node-build /usr/src/app/dist /usr/share/nginx/html

No change to .gitlab-ci.yml is needed for this fix — the same docker buildx build --platform=linux/arm64/v8,linux/amd64 --push command now runs the Node stage once, natively, and only emulates (or runs natively per the strategy below) the lightweight COPY into nginx:stable-alpine.

This only works because the build stage's output has no architecture. If a stage runs npm ci on a package with native addons, or compiles anything, and that output gets copied into a target-arch final stage, the addon would be built for the wrong CPU. That failure shows up later, at container start, as exec format error — see Fix 'exec user process caused: exec format error' on AWS Fargate for what that looks like and why it is an architecture mismatch, not a Docker bug.

Fix 2: build each architecture on its own native runner#

When a stage genuinely needs to run as the target architecture (native modules, a compiled binary), cross-compilation does not apply, and the accepted answer's approach is the one that removes emulation entirely: one job per architecture, each sent to a runner of that architecture, then merged into a single manifest with docker buildx imagetools create:

yaml
stages:
   - build
   - merge
 
build_amd64:
    stage: build
    tags: [amd64]
    script:
      - docker buildx build --push --platform=linux/amd64 -t "$CI_REGISTRY_IMAGE:amd64" .
 
build_arm64:
    stage: build
    tags: [arm64]
    script:
      - docker buildx build --push --platform=linux/arm64 -t "$CI_REGISTRY_IMAGE:arm64" .
 
merge_manifest:
    stage: merge
    script:
      - docker buildx imagetools create -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:amd64" "$CI_REGISTRY_IMAGE:arm64"

docker buildx imagetools create builds a manifest list from existing, already-pushed images — it does not rebuild either architecture, it just points one tag at both (Docker's imagetools create reference). Pushing per-architecture tags first, then merging, avoids two jobs overwriting the same cache tag, which is a real risk in a two-job split that tags both builds identically.

GitLab itself now runs hosted SaaS runners on native Arm64 hardware, documented under machine types for Linux Arm64 (saas-linux-small-arm64 and larger Premium/Ultimate tiers) — tag the build_arm64 job for one of those instead of maintaining a self-hosted arm64 runner, and the step runs on real arm64 silicon with no emulation at all.

Fix 3: if you must keep emulation, shrink what runs under it#

Sometimes neither fix above is an option — the whole image needs native compilation and there is no second runner available. In that case, the only lever left is to reduce how much work QEMU has to translate: cache dependencies so yarn install only re-runs when the lockfile changes (COPY package.json yarn.lock ./ before COPY . ., as in Fix 1), and use --cache-to type=registry or --cache-from type=registry rather than the BUILDKIT_INLINE_CACHE build argument set only as a GitLab CI variable — Docker's own inline-cache documentation shows it has to be passed as --build-arg BUILDKIT_INLINE_CACHE=1 (or --cache-to type=inline) to the docker buildx build invocation; a plain variables: entry in .gitlab-ci.yml never reaches BuildKit unless it is explicitly forwarded.

Verify the fix#

Confirm which stage ran under emulation by printing the architecture it actually executed on. Add, next to the step you are checking:

dockerfile
RUN uname -m

With the original Dockerfile and --platform linux/arm64, that line prints aarch64 — the Node stage really did run emulated. With Fix 1's FROM --platform=$BUILDPLATFORM change, the same RUN uname -m inside the pinned stage prints the builder's own architecture (x86_64 on an amd64 host), proving that stage now runs natively, while the image BuildKit produces for the linux/arm64 target is still tagged and inspectable as arm64:

text
final image architecture (must still be arm64 even though the build stage ran natively):
arm64

Reproduced locally on Docker Desktop (host x86_64, Docker 29.7.2, buildx v0.36.1) with BuildKit's docker-container driver and its bundled QEMU, 16 October 2026, using a node:18-alpine stage that ran uname -m and then a CPU-bound loop (no network, so the timing is not noise from a shared link): on linux/amd64 the loop took 7.4s and uname -m printed x86_64; on linux/arm64 with the unpinned Dockerfile, the same loop took 78.7s and uname -m printed aarch64 — about 10.6x slower, close to the asker's own "almost 10x" estimate. With Fix 1's FROM --platform=$BUILDPLATFORM change, the identical linux/arm64 build ran the loop in 4.0s and printed x86_64, while docker image inspect on the result still reported arm64. Only the RUN step's measured wall time and uname -m output are evidence here — not how the build "felt".

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

One more thing worth checking#

If the pipeline builds for linux/arm64/v8 specifically (the variant suffix in the original .gitlab-ci.yml), double check any base image further down the multi-stage build actually publishes that variant — a missing variant surfaces as a pull failure, not a slow build, and is a different problem from the one here. For a Dockerfile that otherwise builds correctly but produces a /dist layout node cannot find at the stage boundary, see Fix "Cannot find module '/dist/main'" in a Docker build, which covers the other common multi-stage ordering mistake: running the build before the source is copied in, instead of copying first. For a base image whose own ENTRYPOINT keeps overriding a later stage's startup command, see Docker: Override or Remove ENTRYPOINT From a Base Image.

Whichever fix applies, the measurement that matters is which architecture a RUN uname -m (or equivalent) actually printed during the build — not how fast the job felt, and not which strategy a blog post recommends in the abstract.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote