CLOUD · 28 MAY 2026

Docker builds that don't take ten minutes

Moving two lines cut this Node service's rebuild from 36 seconds to 7

DISCIPLINE
CLOUD
PUBLISHED
28 May 2026
READ TIME
10 min
AUTHOR
AlgoCore

You change one line in a route handler, push, and the CI job reinstalls 472 npm packages before it gets anywhere near your change. Nothing about that install is new. The lockfile hasn't moved in three weeks. Docker reinstalls anyway, because of where one COPY sits in your Dockerfile.

I built a TypeScript Express service and measured four Dockerfiles against four kinds of change, three runs each. Some of what I expected to matter didn't.

What's being measured

The service is an orders API: Express 4.21.2, Zod 3.24.1, pg, ioredis, the AWS SDK v3 clients for S3 and SQS, pino, knex, lodash. 151 TypeScript files, 3,870 lines, compiled with tsc 5.7.3. The lockfile resolves to 546 entries; npm ci reports 472 packages installed and node_modules lands at 236 MB. That's a mid-size service — not a toy, not a monorepo.

Every build ran on Docker 29.4.3 with the built-in BuildKit (buildx v0.33.0), containerd snapshotter, overlayfs, on 2 vCPU and 8 GB of RAM. Docker Hub was unreachable from the benchmark box, so the base was a locally assembled Node 22.22.2 rootfs of 104 MB. The Dockerfiles below name node:22-bookworm-slim, which is what you should use; all four variants shared the same substitute base, so the comparisons hold even though the absolute image sizes shift.

Four scenarios, run against each Dockerfile:

  • colddocker builder prune -af first, so no layer cache and no cache mounts survive
  • no change — build again immediately
  • source change — append a line to one file in src/
  • dependency change — add lru-cache to package.json and the lockfile

Timings are the median of three runs, wall clock for the whole docker build. Image sizes come from docker image inspect --format '{{.Size}}'.

Dockerfilecoldno changesource changedep changeimage
v1 naive36.2 s0.4 s35.6 s34.3 s202.3 MB
v2 deps before source34.0 s0.4 s6.6 s33.9 s189.7 MB
v3 + cache mounts32.1 s0.4 s5.4 s26.8 s146.6 MB
v4 multi-stage28.5 s0.4 s5.4 s23.0 s119.3 MB

Run-to-run spread was at most 1.3 s for every cell except the v1 builds, which ranged 34.0–40.2 s cold and 34.5–36.5 s on a source change.

The Dockerfile that grew by accident

This one shows up in a lot of repositories, usually because it was written in five minutes during a spike and never revisited:

FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

It works. It is also structurally incapable of caching anything useful. BuildKit hashes the contents of the build context that COPY . . pulls in, and that hash is the cache key for every layer after it. Touch a README, bump a version in a comment, let CI write a coverage file — the key changes, and npm install runs again.

The measurement confirms the shape: a source-only change costs 35.6 s, which is 98% of a cold build. The npm install step alone takes 13.4 s of that, with an input that hasn't changed.

The other 21 s is worth naming, because most write-ups ignore it. Exporting the image took 14.6 s in the v1 cold build — 10.3 s writing layers plus 4.3 s unpacking into the local store. When your image carries 236 MB of node_modules, export is a first-class cost.

Dependencies before source

The fix is to split the COPY so the lockfile lands in its own layer, ahead of the install:

FROM node:22-bookworm-slim
WORKDIR /app

# Only these two files invalidate the install layer.
COPY package.json package-lock.json ./
RUN npm ci

COPY tsconfig.json ./
COPY src ./src
RUN npm run build

ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "dist/server.js"]

A source-only change now costs 6.6 s instead of 35.6 s. That is the number that matters, because source-only changes are what you actually do all day. The cold build barely moves — 34.0 s against 36.2 s — which is the honest result: layer ordering buys you nothing on a cold builder. It buys everything on a warm one.

Use npm ci, not npm install. npm ci installs exactly what the lockfile says, and when the two files disagree it exits 1 with EUSAGE rather than resolving something new — I checked, with a package.json asking for lru-cache@11.0.2 against a lockfile holding 11.5.3. That makes the layer a pure function of the two files you copied. npm install is free to resolve a new version and rewrite the lockfile inside the image, which makes the layer's output depend on when you built it.

Copying src as its own step rather than COPY . . is what keeps the install layer isolated from everything else in the repository. A stray edit to .github/workflows/ci.yml no longer reinstalls your dependencies.

.dockerignore is part of the ordering

Everything above assumes the build context is small. On a developer's machine it usually isn't, because node_modules is sitting right there.

I built v1 from a working tree that had node_modules present, the way it is on your laptop:

#5 transferring context: 186.00MB 6.3s done
#7 [4/5] RUN npm install
#7 3.467 added 1 package, and audited 473 packages in 3s

Six seconds of every build spent uploading 186 MB to the daemon. The install then finished in 3 s, which looks like a win until you notice why: it reused the node_modules that came in from the host. Those are your machine's binaries, resolved against your machine's platform, now baked into a Linux image. The resulting image also contained .git (8 MB), coverage (7 MB), and docs.

The .dockerignore that fixes it:

node_modules
dist
coverage
.git
.github
docs
test
*.md
.env*
Dockerfile*

Same working tree, v2 build: context transfer drops from 186 MB to 9.71 kB, and from 6.3 s to 0.4 s. BuildKit also supports a per-Dockerfile ignore file — Dockerfile.v2.dockerignore next to Dockerfile.v2 — which is how I kept the four variants honest in one directory.

Cache mounts pay in proportion to how slow your registry is

The standard advice is to add a cache mount for npm's package cache:

RUN --mount=type=cache,target=/root/.npm npm ci

On my benchmark box this saved 1.5 s out of a 15.4 s install. That is not the 5× that gets promised, and the reason is specific rather than interesting: this machine reaches the registry at 26.5 MB/s, so the 40 MB that npm ci pulls down takes under two seconds. The cache mount removes downloads. It cannot remove anything else.

To get the curve rather than one point, I put a throttling proxy in front of registry.npmjs.org — 60 lines of Node, one shared token bucket, tarball URLs rewritten so package downloads are metered too. Then I ran npm ci in a container with the npm cache on a Docker volume, cold and warm, at four speeds:

Registry throughputcold cachewarm cachesaved by the cache mount
26.5 MB/s (unthrottled)15.4 s13.9 s1.5 s
8 MB/s16.0 s12.8 s3.2 s
2 MB/s23.6 s13.4 s10.2 s
1 MB/s44.4 s13.5 s30.9 s

The warm column is flat at roughly 13.5 s across a 26× range of network speed. That is the floor: unpacking the tarballs and linking 472 packages into node_modules, which is CPU and disk work that no cache mount touches. The cold column is the floor plus download time — though not the full sum, since 40 MB at 1 MB/s is 40 s of transfer and the cold build only lost 31 s to it, which is what you'd expect if npm extracts while it fetches.

So the rule is: estimate your win as the bytes your install fetches divided by your effective registry throughput. Get the numerator with du -sb --apparent-size ~/.npm/_cacache after a clean install — 40.7 MB here. If your runners sit next to a registry mirror, the cache mount is close to free and close to worthless. If they pull over a VPN, or you're in a region far from your registry, it's the largest single lever in this article.

The dep-change column still shows v3 beating v2 by 7.1 s, and the per-step log says only half of that is the network. On a dependency change: npm ci 13.7 s → 10.3 s from the warm npm cache, tsc 5.1 s → 3.7 s because v3 also mounts a cache for the incremental build info, and export 14.6 s → 12.2 s. That last one is the subject of the next section, and it was the result I did not see coming. One thing I did not exercise is the sharing option on a cache mount, since I only ran sequential builds.

The cache mount also keeps npm's cache out of your image

This one I didn't expect, and it accounts for most of the 43 MB between v2 and v3.

npm ci writes its package cache to $(npm config get cache), which is /root/.npm when the build runs as root. Without a cache mount, that directory is part of the layer:

$ docker run --rm bench:v2 sh -c 'du -sm /root/.npm'
58      /root/.npm

That's 40.7 MB of tarballs and metadata, costing 58 MB of layer once overlayfs rounds thousands of small files up to block size. You will never read any of it again, and it ships to every host that pulls the image. With the cache mount, the same directory is a mount rather than a layer, so it never gets committed — 1 MB in the v3 image.

Check where npm actually caches before you write the mount path. My first pass mounted /root/.npm on a base image whose npmrc pointed the cache elsewhere; the mount stayed empty, the cache went into the layer, and the build looked exactly like a working one. npm config get cache inside the image settles it.

Cache mounts do not survive --cache-from

This is the part that decides whether any of the above helps your CI, and it is easy to test. Build with the cache mount warm, export the build cache, prune everything, re-import, then look inside the mount. I used a probe Dockerfile with an ARG to defeat layer caching:

FROM node:22-bookworm-slim
ARG BUST=0
RUN --mount=type=cache,target=/root/.npm \
    echo "bust=$BUST" && du -sm /root/.npm

Before pruning, with the builder warm:

58      /root/.npm

After docker build --cache-to type=local,dest=./bcache, docker builder prune -af, and a rebuild with --cache-from type=local,src=./bcache, BuildKit restored 6 CACHED layers from a 140 MB exported cache — and the probe reported:

1       /root/.npm

The layer cache came back. The cache mount didn't. Cache mounts live in the builder's own storage and no cache exporter carries them.

The consequence for CI is concrete. On a fresh ephemeral runner that restores layer cache from a registry or from actions/cache, a dependency change re-runs npm ci with an empty npm cache every time — the cold column of that throughput table, forever. To get the warm column you need a builder whose storage persists between runs: a self-hosted runner, or a long-lived remote builder.

Multi-stage is faster because it's smaller

The last variant splits the build into three stages and installs production dependencies separately for the runtime image:

FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM deps AS build
COPY tsconfig.json ./
COPY src ./src
RUN --mount=type=cache,target=/app/.tscache \
    npx tsc -p tsconfig.json --tsBuildInfoFile /app/.tscache/tsbuildinfo

FROM node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

It installs dependencies twice and still comes out fastest at 28.5 s cold. Two things pay for the duplicate install.

BuildKit runs independent stages concurrently. deps and runtime have no dependency on each other, so their installs overlap — the log shows the production-only install finishing at 11.7 s while the full install is still going at 15.8 s. You pay for the longer of the two, not the sum.

And the runtime image is much smaller, which shows up directly in export time: 5.9 s for v4 against 14.6 s for v1. Production-only node_modules is 124 MB against 236 MB with devDependencies, there's no npm cache in the layer, and dist arrives as a 1.3 MB COPY --from instead of being built in place. The final image is 119.3 MB, 41% smaller than the naive one, and that saving is paid again on every push and every pull.

USER node costs nothing and belongs in the runtime stage. Create the user there if your base image doesn't already have one.

The order to do this in

If you only change one thing, split the COPY and switch to npm ci. That's the 35.6 s → 6.6 s result, it takes four lines, and it needs no BuildKit features.

Write the .dockerignore next. It's the cheapest fix here, and it stops your laptop's node_modules from ending up in a production image.

Go multi-stage third, for the image size. Every pull gets faster, which matters more than build time once you deploy several times a day.

Add cache mounts last, and check two things before you trust them: that npm config get cache matches your mount target, and that your builder's storage survives between CI runs.

What this doesn't show

Two vCPU and a 26 MB/s registry is a specific machine. A build box with 16 cores will compress the install and compile numbers and make export dominate even more; a runner behind a slow link will make the cache mount the headline. The ratios in the tables hold better than the seconds do.

I also measured one package manager and one compiler. pnpm's content-addressed store interacts with cache mounts differently enough that I wouldn't assume any of these numbers carry over, and moving type-checking out of the image build — esbuild for the transpile, tsc --noEmit back in CI — changes which step you should be attacking. Those are the next two I want to run.

Want this applied to your stack?

Most of these findings came out of client delivery. We can run the same passes on your system.

Request a quote
Request a quote