Docker Multi-Stage Builds: Smaller Images, Layer Caching, BuildKit Cache Mounts and Secret Leaks

Key takeaways

Multi-stage builds eliminate build tools from production images — a Node.js app built on the full node image shrinks from over 1GB to a fraction of that. This guide covers stage design, layer caching internals, cache invalidation pitfalls, secret leakage, and production-ready Dockerfiles for Node, Python, and Go.

Why Multi-Stage Builds Exist

The core problem multi-stage builds solve is that the tools you need to build software are rarely the tools you need to run it. A Node.js project needs the TypeScript compiler, dev dependencies, and often native build toolchains (node-gyp, python3, make, g++) to produce a dist/ folder. None of that is needed to execute the compiled JavaScript. A Go project needs the entire Go toolchain and module cache to produce a single static binary — after which the toolchain is pure dead weight. Before multi-stage builds existed (Docker 17.05, 2017), the standard workaround was either shipping the bloated build image to production, or maintaining two separate Dockerfiles and manually copying artifacts between build stages with external scripts. Both approaches were fragile: the two-Dockerfile pattern in particular tends to drift, because nobody remembers to keep the “builder” Dockerfile’s base image or dependency versions in sync with the “runtime” one.

# Single-stage (before)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install          # Installs dev dependencies too
RUN npm run build        # TypeScript -> JavaScript
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Image size: ~1.2GB (node image + all node_modules including devDeps)
# Multi-stage (after)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev   # Production deps only (--only=production is deprecated)
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
# Image size: node:20-alpine base (itself over 100MB uncompressed) + production node_modules + dist

The build stage is discarded once the final docker build finishes — its filesystem, its layer history, and anything written to disk during RUN steps in that stage never reach the pushed image. Only what an explicit COPY --from=build pulls forward survives. This matters beyond raw disk size: a smaller image pulls faster on every deploy (relevant at scale — during a rolling update every node that doesn’t have the image cached pulls it, so a gigabyte-plus image multiplies across the cluster), and it shrinks the attack surface, since a compiler toolchain or package manager sitting in a production image is one more thing a compromised process can potentially abuse (e.g., using gcc or curl present in the image to download and compile a payload).

The trade-off is added Dockerfile complexity and a slightly longer cold build (no cache) because Docker still executes every stage — multi-stage builds don’t skip work, they just decide what gets kept afterward. If you’re optimizing for local development iteration speed rather than production image size, a single-stage image with a bind-mounted source tree is often faster to work with day-to-day; multi-stage is specifically a production/CI concern.


Layer Caching — The Key to Fast Builds, and Where It Silently Breaks

Docker’s build cache works by hashing each instruction together with its inputs (the previous layer’s cache key, the instruction text, and — for COPY/ADD — the content of the files being copied). If that hash matches a previously built layer, Docker reuses it and skips execution entirely. If it doesn’t match, that layer and every layer after it is invalidated and rebuilt, even if those later instructions didn’t actually change. This “waterfall” invalidation is the single most important thing to understand about Dockerfile ordering: instructions should be sorted from least-frequently-changing to most-frequently-changing, because the first instruction to miss the cache poisons everything downstream of it.

# BAD ordering -- source code change invalidates npm install cache
FROM node:20-alpine
WORKDIR /app
COPY . .                 # Changes every commit -- cache miss
RUN npm ci               # Re-runs every time = slow CI

# GOOD ordering -- npm install only re-runs when package.json changes
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./    # Only changes when adding/removing packages
RUN npm ci               # Cached until package.json changes
COPY . .                 # Source code -- cache miss here is fine
RUN npm run build
Illustrative CI build with good caching (times are made up to show the pattern):
  Commit 1 (first build):   npm install: 45s, build: 12s = 57s total
  Commit 2 (code change):   npm install: cached, build: 12s = 12s total
  Commit 3 (new package):   npm install: 45s, build: 12s = 57s total

A few subtleties trip people up here in practice. First, COPY package*.json ./ matches both package.json and package-lock.json (or yarn.lock/pnpm-lock.yaml if you adjust the glob) — if you only copy package.json and not the lockfile, npm ci will fail outright because npm ci requires a lockfile, and if you use npm install instead, an out-of-date lockfile silently produces a different dependency tree in the container than what a developer has locally, since the cache key never accounted for lockfile drift. Second, the cache is keyed on file content, not modification time or file existence, so touching a file without changing its bytes doesn’t bust the cache — but reformatting package.json (even whitespace-only changes from a different npm version) does bust it, because the byte hash differs. Third, this local build cache is ephemeral per host: a fresh CI runner (GitHub Actions, most hosted CI) has no prior layers to reuse unless you explicitly export/import the cache — cache-from/cache-to with type=gha or a registry-backed cache (type=registry) is what actually makes the “commit 2: cached” row in the table above true in a CI environment rather than just on a developer’s laptop.

Fourth, and less obvious: cache correctness depends on your COPY --from=<stage> inputs too. If stage A’s output changes, every downstream stage that does COPY --from=A gets a cache miss on that instruction, even if nothing else in the Dockerfile changed — this is by design, but it means restructuring stages purely to “improve caching” can backfire if you introduce an intermediate stage whose output changes on every build (for example, a stage that embeds a build timestamp) sitting between two otherwise-cacheable stages.


BuildKit Cache Mounts — Keep Package Caches Out of Layers

Layer caching only helps while the layer is unchanged. The moment package-lock.json changes by one line, the npm ci layer is rebuilt from scratch and every package is downloaded again. BuildKit cache mounts fix that by giving a RUN step a persistent cache directory that survives between builds but is never written into the image:

# syntax=docker/dockerfile:1
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

The same pattern works for other package managers: target=/root/.cache/pip for pip, target=/go/pkg/mod plus target=/root/.cache/go-build for Go, and target=/var/cache/apt (with sharing=locked) for apt. BuildKit is the default builder in current Docker versions, so the only requirement is the # syntax line for older engines.

Two caveats. The cache lives on the machine that runs the build, so on ephemeral CI runners it starts empty every time unless the CI system persists BuildKit’s state (for example, with the GitHub Actions cache backend for docker buildx). And a cache mount is not a substitute for a correct lockfile: it speeds up downloads, but the install result still comes from the lockfile, which is what keeps builds reproducible.

.dockerignore — Exclude Unnecessary Files From the Build Context

.dockerignore prevents files from being sent to Docker during COPY/ADD, and more fundamentally, it prevents them from being sent to the Docker daemon at all as part of the build context — this happens before any instruction runs, before the cache is even consulted.

# .dockerignore
node_modules/           # Never copy -- will be installed inside container
dist/                   # Build output -- will be regenerated
.git/                   # Git history not needed in image
.env                    # Secrets -- never copy
.env.local
coverage/
.nyc_output
*.log
.DS_Store
README.md
*.md
__tests__/
**/*.test.ts
**/*.spec.ts
.github/
.vscode/
docker-compose*.yml
Dockerfile*

Without .dockerignore, COPY . . sends every file in the build context — including node_modules/ (which can be hundreds of megabytes) and .git/ (which can easily exceed the source tree itself on an old repository) — to the Docker daemon before any instruction is evaluated. This has two real costs beyond the obvious disk/network overhead: it slows down every single build, cached or not, because context transfer happens up front regardless of cache hits; and it can accidentally leak secrets. A .env file with real credentials sitting in the project root, copied by an unqualified COPY . ., ends up baked into a layer even if the application code never reads it — anyone with docker history or docker save access to that image can extract it. This is one of the most common real-world Docker misconfigurations, and it’s entirely preventable by treating .dockerignore as security-relevant, not just a build-speed optimization.


Node.js: Production-Ready Dockerfile

# === Stage 1: Dependencies ===
FROM node:20-alpine AS deps
WORKDIR /app

# Copy package files (cached until packages change)
COPY package*.json ./
# npm ci = clean install, exact versions from package-lock.json
RUN npm ci

# === Stage 2: Build ===
FROM node:20-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build      # TypeScript -> JavaScript

# Prune dev dependencies
RUN npm prune --production

# === Stage 3: Production ===
FROM node:20-alpine AS runner
WORKDIR /app

# Security: create non-root user
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 --ingroup nodejs nodeuser

# Copy only what's needed
COPY --from=builder --chown=nodeuser:nodejs /app/dist ./dist
COPY --from=builder --chown=nodeuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodeuser:nodejs /app/package.json ./

# Metadata
LABEL maintainer="[email protected]"
LABEL version="1.0"

# Run as non-root
USER nodeuser

EXPOSE 8000

# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
  CMD wget -qO- http://localhost:8000/health || exit 1

CMD ["node", "dist/index.js"]

This is a deliberately three-stage design rather than the more common two-stage pattern, and the reason is worth spelling out because it’s a real trade-off, not just extra ceremony. The deps stage installs the full dependency tree once and is cached independently of source code changes. The builder stage reuses that cached node_modules via COPY --from=deps, then compiles and prunes dev dependencies with npm prune --production — this is more efficient than running npm ci --omit=dev from scratch a second time, because it reuses already-resolved packages instead of re-resolving and re-downloading them. The runner stage then copies the already-pruned node_modules forward. Compare this to a naive two-stage setup that runs npm ci in the builder stage and then npm ci --omit=dev again in the runner stage: it works, but it pays for dependency resolution twice, and if your private registry is slow or rate-limited, that second full install is pure waste.

The COPY --from=builder --chown=nodeuser:nodejs ordering also matters for a subtler reason: --chown is applied as part of the COPY instruction itself, so it doesn’t require a separate RUN chown -R layer afterward — which would otherwise create an extra layer that duplicates the entire node_modules directory’s data on disk (Docker layers are copy-on-write, but a chown that touches every file’s metadata still materializes a full new layer in most storage drivers). Doing the ownership change inline avoids that.

One frequently-missed gotcha: if your package.json declares an optionalDependencies block with platform-specific native binaries (common with tools like sharp, esbuild, or swc), pruning or copying node_modules across a stage boundary can silently drop the correct platform binary if the deps stage’s base image architecture doesn’t match the runner stage’s — this is invisible until runtime, where you get a “cannot find module” error that has nothing to do with your actual code.


Python: Optimized Dockerfile

# === Stage 1: Build dependencies ===
FROM python:3.12-slim AS builder
WORKDIR /app

# Install build tools (only needed for compiling wheels)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc libpq-dev \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# === Stage 2: Production ===
FROM python:3.12-slim AS runner
WORKDIR /app

# Runtime dependencies only (no gcc, no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    && rm -rf /var/lib/apt/lists/*

# Copy installed packages from builder
COPY --from=builder /root/.local /root/.local

# Copy application
COPY . .

# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
USER appuser

ENV PATH=/root/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1

EXPOSE 8000

CMD ["gunicorn", "app.main:app", "--workers", "4", "--bind", "0.0.0.0:8000"]

The reason gcc and libpq-dev (the PostgreSQL client headers, needed to compile psycopg2 from source) appear only in the builder stage is that most Python C-extension packages need a compiler to build wheels the first time, but once built, the resulting .so files are self-contained binaries that only need the runtime shared library — libpq5 here, not the -dev headers package. This split alone typically removes 200-400MB from the final image, since gcc and its transitive dependencies are surprisingly large on Debian-based images. pip install --user is what makes the cross-stage COPY --from=builder /root/.local /root/.local possible — it installs packages into a user-local directory instead of the system site-packages, which makes them easy to copy as a self-contained unit rather than needing to figure out exactly which system paths pip scattered files into.

A practical pitfall here: if your production dependencies include a package that also needs a runtime shared library beyond libpq5 (for example, Pillow needs libjpeg/zlib at runtime, not just at build time), forgetting to install that runtime library in the runner stage produces an ImportError: libjpeg.so.X: cannot open shared object file at container startup — not at build time, since the import only happens when the application actually runs. This class of bug is exactly why “it built successfully” is not the same as “it will run successfully” with multi-stage builds; the two things are validated by different steps, and only a real container run (or a CI smoke test that boots the image) catches missing runtime shared libraries.


Go: Tiny Static Binary

Go compiles to a single static binary, so the final image can be FROM scratch — there is no OS, no shell, no package manager, nothing except the binary itself:

# === Build stage ===
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Download dependencies (cached until go.mod/go.sum change)
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build static binary -- no CGO, fully self-contained
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \          # Strip debug info (smaller binary)
    -o /app/server ./cmd/server

# === Final stage: minimal image ===
FROM scratch
# Or use: FROM gcr.io/distroless/static-debian12 (adds CA certs, timezone data)

COPY --from=builder /app/server /server

# Copy TLS certificates for HTTPS outbound requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

EXPOSE 8080
ENTRYPOINT ["/server"]
# Final image size: ~10MB (just the binary)

CGO_ENABLED=0 is the load-bearing flag here: with cgo enabled (the default on most platforms), Go dynamically links against libc, which means the resulting binary depends on shared libraries that don’t exist in a scratch image — it will fail to start with a cryptic “no such file or directory” error even though the binary file is clearly present, because the dynamic linker itself is what’s missing. Disabling cgo forces a fully static binary at the cost of losing access to any package that requires cgo (notably some DNS resolution modes and certain SQLite drivers). The TLS certificate COPY is the other easy-to-forget line: scratch has no CA certificate bundle, so any outbound HTTPS call from the binary fails TLS verification unless you explicitly copy /etc/ssl/certs/ca-certificates.crt forward from a stage that has it.

The trade-off against scratch is debuggability: there’s no shell to docker exec into for troubleshooting, no cat, no ls. gcr.io/distroless/static-debian12 is a common middle ground — it adds CA certs and timezone data but still has no shell or package manager, which is usually the right default unless you have a concrete reason to go all the way to scratch.


C and C++: Builder and Runtime Stages

Compiled C++ follows the same pattern as Go, with one difference that causes most of the failures: C++ binaries usually link dynamically against the C++ runtime (libstdc++) and often other shared libraries, so the runtime stage must provide them.

# ========== Stage 1: build ==========
FROM ubuntu:22.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
    g++ cmake git ca-certificates \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY CMakeLists.txt .
COPY src/ src/
RUN cmake -B build -DCMAKE_BUILD_TYPE=Release \
    && cmake --build build --target myapp

# ========== Stage 2: runtime ==========
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libstdc++6 libgcc-s1 \
    && rm -rf /var/lib/apt/lists/*
COPY --from=builder /src/build/myapp /usr/local/bin/
USER nobody
CMD ["myapp"]

With a package manager such as vcpkg or Conan, install the dependencies in the builder stage and copy only the finished binary (plus any shared libraries it needs) into the runtime stage. Copy the dependency manifest (vcpkg.json, conanfile.txt) and install before copying the source, so dependency builds stay cached when only your code changes. Those builds can take many minutes, so this ordering matters more than usual.

Match the C library between stages. A binary built on Ubuntu 22.04 needs a glibc at least as new as the one it was built against. Running it on an older base image fails with “version GLIBC_2.34' not found". Running a glibc binary on **Alpine**, which uses musl instead of glibc, usually fails with a confusing exec: no such file or directory, even though the file exists: what is missing is the glibc dynamic loader the binary asks for. Use a Debian-based slim image or distroless (gcr.io/distroless/cc-debian12` includes libstdc++) for glibc builds, or build inside Alpine if you standardize on musl.

Find missing libraries before shipping. Run ldd build/myapp in the builder stage to list every shared library the binary needs. Anything not provided by the runtime base image must be installed there or copied with COPY --from=builder. An “error while loading shared libraries: libfoo.so.3” at container start means one was missed.

Static linking is an option, with trade-offs. -static-libstdc++ -static-libgcc removes the C++ runtime dependency while keeping glibc dynamic, which is a common middle ground. Fully static binaries (-static) can run on scratch, but static glibc has known problems with DNS resolution and dlopen, so fully static C++ builds are usually done against musl instead. Check the license terms of any library you link statically.

Secrets in Build — Don’t Bake Them In

# NEVER DO THIS -- secret becomes visible in docker history
RUN echo "machine github.com login user password $GITHUB_TOKEN" > ~/.netrc

# BuildKit secrets -- available during build, not persisted in any layer
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) \
    npm install --registry https://npm.pkg.github.com
# Build with secret
docker build \
  --secret id=github_token,env=GITHUB_TOKEN \
  -t my-app .

This is worth understanding at the mechanism level, because “it’s in a discarded build stage so it’s safe” is a common and dangerous misconception. A discarded stage protects you from files that stage wrote to disk and never COPY --from’d forward. It does not protect you from values passed as ARG or ENV, or from anything written into a RUN command’s shell history equivalent — Docker records the exact command string for every RUN instruction as part of that layer’s metadata, and docker history --no-trunc <image> (or simply inspecting the image manifest) reveals it, even for a stage that never made it into the final image, if that intermediate stage image or its layers are ever pushed, cached, or retained by a shared builder. On top of that, if you use --build-arg GITHUB_TOKEN=$TOKEN instead of --secret, the value becomes part of the build’s cache key and is inspectable via docker inspect on any image built from that Dockerfile, indefinitely.

--mount=type=secret solves this properly: BuildKit mounts the secret as a file at /run/secrets/<id> only for the duration of that specific RUN instruction, and it is never written to any layer or cache entry — the file simply doesn’t exist once the instruction finishes. This is the difference between “the secret is in a part of the image that gets deleted” (still risky) and “the secret was never part of any image layer to begin with” (actually safe). If your CI pipeline still uses ARG-based secrets because a legacy build script predates BuildKit secrets, that’s a concrete, fixable security gap worth prioritizing over most other Docker optimizations on this page.


Multi-Platform Builds

Build for multiple architectures (AMD64 + ARM64, relevant for M-series Macs in development and AWS Graviton or other ARM instances in production):

# Enable multi-platform builder
docker buildx create --use --name multiplatform

# Build and push for both architectures
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myrepo/my-app:latest \
  --push \
  .
# GitHub Actions: multi-platform CI build
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    platforms: linux/amd64,linux/arm64
    push: true
    tags: myrepo/my-app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The practical catch with buildx --platform is that building for a non-native architecture typically runs under QEMU emulation on the builder host, which is much slower than a native build for compilation-heavy stages, because every CPU instruction of the compiler is translated in software — a Go or Rust build that is quick natively can take many times longer emulated. For compiled languages, cross-compilation from a single native builder (e.g., Go’s built-in GOARCH=arm64 GOOS=linux go build without needing an emulated ARM container at all) is usually faster than emulating the entire build stage, and it’s worth structuring your Dockerfile to cross-compile in the builder stage while only running the lightweight final stage under emulation (or not emulating at all, since copying a pre-built static binary into a scratch or distroless final stage requires no execution of foreign-architecture code during the build itself).


Build Arguments

FROM node:20-alpine AS runner

# ARG for build-time variables
ARG APP_VERSION=unknown
ARG BUILD_DATE

# Convert to ENV to make it available at runtime
ENV APP_VERSION=$APP_VERSION
ENV BUILD_DATE=$BUILD_DATE

# ...
docker build \
  --build-arg APP_VERSION=$(git describe --tags) \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  -t my-app .

ARG values participate in the layer cache key from the point they’re declared onward, which is exactly why embedding something like BUILD_DATE this early in a Dockerfile is a common mistake: because the value changes on every single build, it invalidates every subsequent instruction’s cache, even ones with no actual relationship to the timestamp. The fix is ordering — declare volatile ARGs like a build date or git commit hash as late as possible in the Dockerfile, ideally right before the CMD/ENTRYPOINT, so they don’t sit upstream of expensive, otherwise-cacheable steps like dependency installation.


Common Mistakes That Undermine Multi-Stage Builds

A few patterns come up repeatedly in real Dockerfiles and are worth naming explicitly, since none of them produce an obvious build failure — they degrade image size, cache effectiveness, or security silently:

  • COPY --from ordering that defeats caching. Structuring stages so a frequently-changing stage sits upstream of an expensive, otherwise-stable one (for example, copying application source into an intermediate stage before installing dependencies in that same stage) reintroduces the exact cache-busting problem multi-stage builds are supposed to avoid. The stage boundary doesn’t automatically fix ordering mistakes within a stage.
  • Copying more than the artifact. COPY --from=builder /app ./ (copying the entire builder working directory) instead of COPY --from=builder /app/dist ./dist (copying only the compiled output) silently drags source files, test fixtures, and sometimes .git metadata from the builder stage into the final image, defeating much of the size reduction multi-stage builds are meant to provide.
  • Leaking build secrets via ARG/ENV instead of BuildKit secrets, as covered above — the most security-relevant mistake on this list, and the easiest to miss in code review because the Dockerfile “looks fine” at a glance.
  • Copying the full build context before copying only manifest files. COPY . . followed by RUN npm ci (rather than copying package*.json first) is the single most common cache-invalidation mistake, and it’s easy to introduce accidentally when refactoring a Dockerfile without re-checking instruction order.
  • Using :latest or an unpinned base image tag in a production Dockerfile. Beyond the well-known reproducibility problem, it also silently invalidates your cache whenever upstream pushes a new :latest image, since the base layer’s digest changes even though your Dockerfile text didn’t.

Image Size Reference

Rough, order-of-magnitude figures for small apps; your numbers depend on the base image tag and how many dependencies you ship. Measure with docker images after each change.

App typeBefore multi-stageAfter multi-stage
Node.js (TypeScript)~1.2 GB~150–300 MB (alpine base + production deps)
Python (FastAPI)~900 MB~150 MB
Go (static binary)~350 MB~10 MB
Java (Spring Boot)~500 MB~150 MB (distroless)

Security checklist for the final image

# GOOD: use specific version tags (not :latest)
FROM node:20.11-alpine3.19

# GOOD: non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# GOOD: read-only filesystem (set at runtime)
# docker run --read-only --tmpfs /tmp my-app

# GOOD: no secrets in ENV/ARG at build time
# Use --mount=type=secret at build time, or runtime env vars/orchestrator secrets

# GOOD: minimal base image
# Prefer: alpine, slim, distroless, scratch -- in roughly that order of caution

# GOOD: scan for vulnerabilities before shipping
# docker scout cves my-app:latest
# trivy image my-app:latest

Frequently Asked Questions (FAQ)

Q. Why did my multi-platform build become so slow after adding linux/arm64?

A. When docker buildx build --platform linux/amd64,linux/arm64 runs on an amd64 host, the arm64 stages usually execute under QEMU emulation, and compilation-heavy steps slow down dramatically. For compiled languages, cross-compile inside a native builder stage instead, for example Go with GOOS=linux GOARCH=arm64 go build, and copy the resulting static binary into a scratch or distroless final stage. That final stage runs no foreign-architecture code during the build, so emulation cost mostly disappears.