What a multi-stage build does and how layer caching decides rebuild speed

A multi-stage build is a Dockerfile with more than one FROM instruction. Each FROM starts a new stage with its own base image, and a later stage can pull specific files from an earlier one with COPY --from=<stage>. For a Node.js service, this lets one stage hold compilers, dev dependencies and TypeScript sources while the final stage receives only the compiled output and production node_modules. Everything left behind in earlier stages is excluded from the shipped image.

The technique serves two goals. The first is a small final image, because build tooling never reaches production. The second is fast rebuilds, which depend on how Docker reuses work between builds.

How layer caching works

Docker treats each instruction as a build step and caches its result. RUN, COPY and ADD produce filesystem layers. Other instructions such as ENV and WORKDIR record metadata. When you rebuild, Docker reuses a cached step only if its inputs are unchanged. For COPY and ADD, the inputs are the checksums of the copied files. For RUN, the input is the instruction text. Every step also depends on the base image and on all the steps before it.

When one step's inputs change, that step and every step after it rebuild. This is why instruction order controls rebuild time. Suppose COPY . . comes before npm ci. Then any source edit reinstalls every dependency.

Backend and platform teams benefit most when they ship Node.js services with a build step and rebuild many times a day.

 Rebuild after editing only src/index.ts

 +-- Stage 1: build (FROM node AS build) ---------------+
 | WORKDIR /app            cached   metadata step       |
 | COPY package*.json ./   cached   same file checksums |
 | RUN npm ci              cached   same text           |
 |------------------------------------------------------|
 | COPY . .                REBUILD  checksum changed    |
 | RUN npm run build       REBUILD  parent rebuilt      |
 +------------------------------------------------------+
            |                                             |
            | COPY --from=build                           | left behind
            v                                             v
 +-- Stage 2: runtime (FROM node) ----------+   +-- Not shipped -----+
 | COPY --from=build /app/dist ./dist       |   | dev dependencies   |
 | + production node_modules only           |   | TS sources         |
 | CMD ["node", "dist/index.js"]            |   | compilers          |
 | => small final image                     |   +--------------------+
 +------------------------------------------+

 A changed input rebuilds that step and every step after it.

Placing dependency manifests and npm ci before COPY . . means a source-only edit reuses the cached dependency install and rebuilds only the later steps, while the runtime stage copies just the compiled output and production dependencies.

Ordering instructions so dependencies stay cached

The fix is to split the copy into two steps. First copy only package.json and package-lock.json, then run npm ci, then copy the rest of the source. Docker computes the cache key for a COPY from the checksums of the files it copies. Editing a route handler therefore leaves the manifest layer unchanged. The npm ci layer is reused, and only the final COPY and the steps after it run again. Dependencies are reinstalled only when the manifest or lockfile changes.

The lockfile is a precondition

npm ci installs exactly what the lockfile records and fails if the lockfile and package.json disagree. Without a committed lockfile, the same package.json can resolve to different versions on different days. Builds stop being reproducible. If the lockfile is generated inside the image or ignored in git, the cache key no longer reflects what actually gets installed. Commit package-lock.json and treat changes to it as dependency changes.

Keep the context clean with .dockerignore

A .dockerignore at the repository root should exclude:

  • node_modules
  • .git
  • .env and .env.*
  • coverage and other test output
  • dist or build

This does three things:

  • Faster transfer. It keeps the build context small, so less data is sent to the builder.
  • No host-built native modules. node_modules from a developer's machine may contain native binaries compiled for a different OS or architecture. Excluding it stops COPY . . from overwriting the modules installed in the image.
  • Stable cache. Files like .git internals or coverage reports change constantly. Excluding them stops those changes from invalidating the source COPY layer when no real code changed.
# Writes the two files that keep the dependency layer cached.

cat > Dockerfile <<'EOF'
FROM node:20-slim AS build
WORKDIR /app

# 1. Copy only the manifest and lockfile. This layer's cache key
#    depends on these two files alone.
COPY package.json package-lock.json ./

# 2. Install exactly what the lockfile records. This layer is reused
#    until package.json or package-lock.json changes.
RUN npm ci

# 3. Copy the source last. Editing app code invalidates only this
#    step and the steps after it.
COPY . .
RUN npm run build
EOF

cat > .dockerignore <<'EOF'
# Host-installed modules may hold native binaries built for another OS/arch
node_modules

# VCS metadata changes constantly and is not needed in the image
.git

# Local secrets and env files stay out of the context
.env
.env.*

# Test artifacts and build output are regenerated, not copied in
coverage
dist
build
EOF

A shell snippet that writes a Dockerfile, which copies package.json and package-lock.json and runs npm ci before copying the source, and a .dockerignore that keeps node_modules, .git, env files, coverage and build output out of the build context.

Implementing the stages: build, production dependencies, runtime

Build stage

Start from a full Node base image and run npm ci with all dependencies, including devDependencies. The compiler, bundler and type definitions live there. Then run your TypeScript compilation or bundling step. This stage can be large and messy because nothing in it ships unless you copy it out.

Production dependencies stage

Use a separate stage to produce a node_modules with only runtime packages. Either run npm ci --omit=dev against the same lockfile, or copy the build stage's tree and run npm prune --omit=dev. A fresh install is easier to reason about. Keeping this stage separate lets its layer cache track only the lockfile, not your source.

Runtime stage

The final stage starts clean and uses COPY --from to pull in two things: the production node_modules and the compiled output. It gets no source, no compiler and no build cache. Then:

  • Set NODE_ENV=production. Many libraries change behavior based on it, for example by disabling verbose error output.
  • Run as a non-root user. The official Node images include a node user. If the process is compromised, the attacker does not have root inside the container.
  • Use the exec form of CMD (the JSON array form). The shell form wraps Node in /bin/sh, which may not pass SIGTERM on to Node, so graceful shutdown never runs.
  • Decide on an init process. When Node runs as PID 1, it gets no default signal handling and does not reap orphaned child processes. If your app spawns children, or you have not written your own signal handlers, use docker run --init or install a small init such as tini.

One warning: if a package your code imports at runtime is listed under devDependencies, the build stage still succeeds. The failure only shows up after deployment, as a "cannot find module" error. Run a smoke test against the final image in CI to catch this before it ships.

# ---- Stage 1: build (all deps, including devDependencies) ----
FROM node:22-slim AS build
WORKDIR /app
# Copy manifests first so the npm ci layer is reused until the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Assumes "build" runs tsc or a bundler and writes output to dist/
RUN npm run build

# ---- Stage 2: production-only node_modules ----
FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
# Cache for this layer depends only on the lockfile, not on source changes.
# Anything imported at runtime but listed in devDependencies is missing here,
# and you will only find out when the container starts.
RUN npm ci --omit=dev

# ---- Stage 3: runtime (no source, no compiler) ----
FROM node:22-slim AS runtime
# Small init to forward signals and reap zombies (alternative: docker run --init)
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
WORKDIR /app
# Files stay root-owned, so the app user can read but not modify them
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
# Unprivileged user that ships with the official Node images
USER node
# Exec (JSON array) form: no /bin/sh wrapper, so tini and Node receive SIGTERM
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

A three-stage Dockerfile that compiles with all dependencies, installs production-only node_modules in a separate cached stage, and assembles a runtime image that runs as the non-root node user under tini with exec-form CMD.

Measuring image size and keeping CI rebuilds fast

Start by measuring. docker images lists the final size of each tagged image, so build the single-stage and multi-stage variants under different tags and compare them directly. To see where the bytes come from, run docker history <image>. It shows each layer with the instruction that created it and its size. A large layer usually points to one of three causes: a node_modules that still contains dev dependencies, a build cache left inside the image, or source files copied into the runtime stage that it does not need.

For rebuild speed, BuildKit cache mounts help in the case that layer caching cannot handle. When the lockfile changes, the install layer is invalidated and runs again. If that RUN instruction mounts npm's download cache (for example --mount=type=cache,target=/root/.npm), packages that are already downloaded are reused instead of fetched again. The mounted directory is not written into the image, so it adds nothing to image size. This syntax requires BuildKit and has no effect in the legacy builder.

On ephemeral CI runners, every job starts with an empty builder. Exporting the layer cache with docker buildx build --cache-to and --cache-from (for example, to a registry) restores unchanged layers. Cache mount contents are not included in that export, so on fresh runners they help only if the builder itself is persistent.

Common causes of cache invalidation:

  • COPY . . placed before the install step, which means any source edit invalidates the dependency layers.
  • An ARG such as a commit SHA or timestamp declared early and used in a RUN, which changes on every build.
  • Unpinned base tags like node:lts, which change when the tag moves. Pin the base image by digest so it only changes when you update it.
# syntax=docker/dockerfile:1
# Pin the base by digest so it only changes when you update it on purpose.
# Find the current digest with: docker buildx imagetools inspect node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:<pinned-digest> AS build
WORKDIR /app

# Copy only the manifests first so source edits don't invalidate the install layer.
COPY package.json package-lock.json ./
# The cache mount keeps npm's download cache across builds.
# It is not written into the image.
RUN --mount=type=cache,target=/root/.npm npm ci

# Source changes invalidate only the layers from here down.
COPY . .
RUN npm run build

FROM node:22-bookworm-slim@sha256:<pinned-digest> AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
# Install production dependencies only, reusing the same download cache.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=build /app/dist ./dist

# Declare per-build values last. A changing commit SHA then
# invalidates only this final metadata layer.
ARG GIT_SHA=unknown
LABEL org.opencontainers.image.revision=$GIT_SHA

USER node
CMD ["node", "dist/server.js"]

A cache-friendly multi-stage Dockerfile: it pins the base image by digest, copies the lockfile before the source, mounts npm's download cache during installs, and declares the per-build commit SHA last so it doesn't invalidate earlier layers.

Choosing a runtime base: full, slim, Alpine or distroless

The runtime stage's base image decides what ships to production. The builder stage can stay on the full image because its compilers and headers never leave it.

Base What it removes Native addons Attack surface Debugging
Full (Debian) Nothing significant. It includes a shell, apt, and build tools Prebuilt glibc binaries work Largest Shell and package manager available
Slim (Debian) Build toolchain and most optional system libraries glibc, so prebuilt binaries usually work. A missing shared library can still fail at load time Smaller Shell available. Install tools at runtime only as a last resort
Alpine glibc, replaced by musl. Uses apk Addons without musl prebuilds compile from source Small Shell available
Distroless Shell and package manager. Keeps only runtime libraries glibc-based, so copy modules from a Debian builder Smallest No shell. Plan ahead

Native modules compiled in the builder stage are linked against that stage's libc and built for its CPU architecture. If you build on Debian and run on Alpine, the addon fails to load. The same happens if you build for amd64 and deploy to arm64. Keep the builder and runtime on the same libc family, and build per architecture. On Alpine, this also means that a package that installs prebuilt binaries quickly on Debian may fall back to a source compile. That compile needs Python and a C++ toolchain in the builder, and it slows cold builds.

Before adopting distroless, decide how you will investigate a failing container. Options include:

  • A debug variant of the image that adds a minimal shell
  • Ephemeral debug containers attached to the running pod
  • Structured logs and health endpoints good enough that you rarely need a shell

Pin the runtime base by tag or digest, and rebuild on a schedule. A pinned image never changes on its own, so scheduled rebuilds are how you pick up OS and Node security patches.

When to avoid multi-stage builds, and the alternatives

Multi-stage builds pay off when there is something to leave behind: a compiler toolchain, TypeScript sources, dev dependencies. A small service written in plain JavaScript, with no build step and a handful of dependencies, has little to discard. Splitting it into stages produces a longer Dockerfile that every new team member has to read, while the final image ends up roughly the same size as a single-stage one.

Alternatives worth considering

  • Single stage on a slim base. Copy the package manifests and lockfile first, install production dependencies only, then copy the source. Add a .dockerignore that excludes node_modules, .git, test fixtures and local env files. This gets you most of the caching benefit and keeps the build context small.
  • Build outside Docker. If CI already compiles and bundles the app, copy the finished artifact into a minimal runtime image. The Dockerfile shrinks to a few lines. The tradeoff is that the build now depends on the CI environment, so local and CI builds can drift apart.
  • Stay on a full base. Native modules that compile against system libraries, or incidents where you need a shell and standard tools, can justify the extra size.

Size is not security

A smaller image removes OS packages you don't use. It does nothing about a vulnerable package in your lockfile, which ships either way. A pinned base also gets no fixes unless someone rebuilds it. You still need dependency scanning, and the scheduled rebuilds described above.

Impact on cost and engineering time

The costs land mostly once, on whoever owns the Dockerfile. The savings recur on every build and deploy. That asymmetry usually favours multi-stage builds, but only if the layer cache survives between CI runs. Without cache persistence, you pay the complexity and get only the image size reduction.

Item Cost or saving How often it applies
Writing and reviewing a multi-stage Dockerfile Cost Once, plus edits when the build changes
Persisting the build cache in CI Cost Setup once, then occasional debugging of stale or missed caches
No shell in distroless images Cost Every incident that needs in-container inspection
glibc/musl mismatch with native modules Cost When a native dependency is added or upgraded
Skipping the dependency install on cache hits Saving Every build where only source code changed
Smaller image to push, pull and store Saving Every deploy, node scale-up and retained tag

Illustrative estimate (assumed numbers, not a benchmark)

Assume 40 CI builds per day, a 3-minute dependency install, and 80% of builds changing only source files.

CI minutes saved per day = builds × cache hit rate × install time = 40 × 0.8 × 3 = 96 minutes. Over 20 working days, that is 32 hours of pipeline time, part of which is engineers waiting. Put in your own numbers. If your hit rate is near zero, your savings are near zero too.

Adopting in stages

  • Reorder instructions so the lockfile is copied before the source code. This is cheap and needs no new stages.
  • Split into build and runtime stages on a slim base.
  • Persist the CI cache and check hit rates in your build logs.
  • Move to distroless only after the team has a debugging workflow that doesn't need a shell.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to docker multi-stage build node.js, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.