Bekzod Erkinov
Listen to Article
Loading...Table of contents · 12 sections
Docker Layer Caching: Only One Trick Moved My Build Time
On a small Node app, copying package.json and the lockfile before the source cut a source-edit rebuild from a median 39.3 s to 6.3 s. Cache mounts, multi-stage builds and registry cache export did not give me a clear speedup, though two of them shrank the image. Here is what each technique did, measured on one laptop.
Which technique helps which scenario (medians of three runs, details below):
| Scenario | What helped | Result in my test |
|---|---|---|
| Source edit | Manifests before source (variant 2) | 39.3 s to 6.3 s |
| Dependency change | A warm cache mount, npm ci step only |
6.7-9.6 s warm vs 10.1-19.6 s empty |
| Image size | Multi-stage with --omit=dev |
441 MB to 343 MB |
| Build context size | .dockerignore |
50.15 MB to 202 B |
| Fresh CI runner | Exporting the cache (type=registry or type=gha) |
Restores layers; timings inconclusive |
Setup: one Windows laptop, Docker Desktop (Engine 29.7.2, Buildx v0.36.0-desktop.1, 8 CPUs, about 8 GB RAM), 2026-10-11, one home network. Warm scenarios are the median of three runs with the range; cold is a single run. Three runs is a small sample, so treat gaps smaller than the ranges as noise. Raw logs are matrix2.txt, registry.txt and mounttest.txt.
How the Docker build cache decides to rebuild
Every Dockerfile instruction becomes a layer. If the instruction and its inputs match a cached layer, BuildKit reuses it; once one layer misses, every later layer misses. The Docker docs on optimizing cache say to put expensive steps near the beginning, put steps that change often near the end, and copy the package management files before the project code.
Two rules from the cache invalidation page:
COPYandADDcompute a checksum from file metadata, and the modification time is not taken into account, sotouch-ing a file doesn't bust the cache.- The cache for
RUNinstructions isn't invalidated automatically between builds. A test is near the end.
The test app and how I measured
A tiny Express server bundled with esbuild on node:22-slim. node_modules is 55 MB on disk; package-lock.json is 89,632 bytes.
package.json:
{"name":"cache-demo","version":"1.0.0","private":true,
"scripts":{"build":"esbuild src/server.js --bundle --platform=node --outfile=dist/server.js && tsc --version >/dev/null"},
"dependencies":{"express":"4.21.2","lodash":"4.17.21","zod":"3.24.1","pg":"8.13.1"},
"devDependencies":{"esbuild":"0.24.2","typescript":"5.7.2","eslint":"9.17.0"}}
src/server.js:
const express = require("express");
const _ = require("lodash");
const app = express();
app.get("/", (req, res) => res.json({ ok: true, words: _.words("hello docker cache") }));
app.listen(3000);
Generate the lockfile with npm install --package-lock-only. Each build is timed by bench.sh, which wraps docker buildx build --progress=plain, reports the transferred context size and counts CACHED lines:
#!/bin/bash
f=$1; t=$2; shift 2
s=$(date +%s%N)
out=$(docker buildx build --progress=plain -f $f -t $t --load "$@" . 2>&1)
rc=$?
e=$(date +%s%N)
ctx=$(echo "$out" | grep 'transferring context' | grep -o 'context: [0-9.]*[kMG]*B' | tail -1)
echo "$t $(awk -v a=$s -v b=$e "BEGIN{printf \"%.1f\", (b-a)/1e9}") s rc=$rc | $ctx | CACHED=$(echo "$out" | grep -c " CACHED")"
[ $rc -ne 0 ] && echo "$out" | tail -15
Four scenarios per variant:
- Cold:
docker builder prune -affirst, so nothing is cached, cache mounts included. - No change: rebuild with nothing touched.
- Source edit: change the string in
src/server.js. - Dependency change:
npm install --package-lock-only dayjs@<version>.
Per variant: prune, one cold build, then three rounds of (no change, source edit, dependency change) with a different string or dayjs version (1.11.13, 1.11.12, 1.11.11) each round.
Variant 1: copy everything, then install
FROM node:22-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node","dist/server.js"]
The baseline most tutorials start with. Any change to any file busts COPY . ., so npm ci reruns on every edit. Source-edit builds took 24.0, 47.0 and 39.3 s (median 39.3 s), about as long as the 22.9 s cold build. Image size: 441 MB.
Variant 2: copy the manifests first
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node","dist/server.js"]
Now npm ci only reruns when the manifest or lockfile changes. Source-edit median 6.3 s (5.6-8.2 s) versus 39.3 s (24.0-47.0 s) for variant 1. A dependency change stayed at a median 26.7 s (18.7-29.6 s), because adding a package legitimately invalidates the install layer.
Use npm ci (or pip install -r with a lock, go mod download) rather than a command that rewrites the lockfile, so the cached layer's input is the lockfile.
.dockerignore: keep junk out of the context
COPY . . sends everything in the directory to the builder. I had run npm ci locally, so node_modules sat in the folder. Same Dockerfile, with and without the file:
# without .dockerignore
#4 transferring context: 50.15MB 10.2s done
# with it
#4 transferring context: 202B 0.2s done
On a cold build with the file, the transfer was about 90 kB (context: in matrix2.txt). The file:
node_modules
dist
.git
Dockerfile.*
.env*
Beyond transfer time, a host node_modules built for macOS or Windows can land in a Linux image, and .env files can end up in a layer.
Cache mounts (--mount=type=cache)
A cache mount keeps the package manager's download cache between builds, outside the image. Variant 3 is variant 2 with this install line:
RUN --mount=type=cache,target=/root/.npm npm ci
The docs show the same pattern for npm, pip, Go and apt, and warn that using the wrong cache mount options can lead to unexpected behavior.
To see the effect I timed only the npm ci step inside the RUN, after a lockfile change, with a warm mount (populated by a build of the original lockfile) against a fresh mount id (Dockerfile.mt, driven by mounttest.sh):
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
ARG MID
RUN --mount=type=cache,target=/root/.npm,id=$MID sh -c 's=$(date +%s%N); npm ci >/dev/null 2>&1; e=$(date +%s%N); echo NPMCI_MS=$(( (e-s)/1000000 ))'
populate warm mount (original lockfile): NPMCI_MS=8566
[email protected] warm mount: NPMCI_MS=9643
[email protected] empty mount: NPMCI_MS=19571
[email protected] warm mount: NPMCI_MS=8891
[email protected] empty mount: NPMCI_MS=10859
[email protected] warm mount: NPMCI_MS=6651
[email protected] empty mount: NPMCI_MS=10114
Warm was faster in all three pairs: 6.7-9.6 s against 10.1-19.6 s, a saving of a few seconds on a small install. I only tested npm; the mount is more likely to matter for slow or large installs such as pip, apt and Go. In the whole-build matrix, variant 3's dependency-change median was 24.4 s against 26.7 s for variant 2, with overlapping ranges.
The mount also left the image smaller (408 MB against 441 MB in the matrix). A fresh rebuild of each image showed the npm cache is baked into variant 2's layer and absent from variant 3's:
v2: 19M /root/.npm (image 438MB)
v3: 12K /root/.npm (image 406MB)
That accounts for about 19 MB of the 32 MB difference; I don't know what the rest is.
The mount lives in the builder's local storage, so a fresh CI runner starts with an empty one. The gha backend docs don't mention cache mounts, and I did not test whether type=gha or type=registry exports their contents.
Multi-stage builds: smaller image, not faster builds
FROM node:22-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 src ./src
RUN npm run build
FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:22-slim
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
CMD ["node","dist/server.js"]
The final image has production dependencies and the bundle, with no eslint, typescript or esbuild. Size: 343 MB against 441 MB for variant 1. What was removed, from du -sh and ls /app/node_modules | wc -l run inside fresh rebuilds of v2 and v4:
v2: 58M /app/node_modules (157 entries) 19M /root/.npm
v4: 12M /app/node_modules (87 entries) /root/.npm: no such file
Dev dependencies and the npm cache account for most of the difference. Per the multi-stage docs, "BuildKit only builds the stages that the target stage depends on."
Times: no-change median 4.9 s (variant 2: 3.6 s), source edit 10.3 s (variant 2: 6.3 s), dependency change 22.5 s (26.7 s). Choose multi-stage for the image size, not for build speed.
Results: all four Dockerfiles in one table
Seconds; median of three with range for warm scenarios, single run for cold. Docker Desktop runs builds inside a VM, and cold times vary run to run (variant 3 cold was 22.0 s here and 41.6 s in an earlier matrix, matrix1.txt).
| Variant | Cold | No change | Source edit | Dependency change | Image size |
|---|---|---|---|---|---|
1. COPY . . first |
22.9 | 4.1 (2.4-5.0) | 39.3 (24.0-47.0) | 26.8 (26.5-56.8) | 441 MB |
| 2. Manifests first | 24.9 | 3.6 (3.6-4.2) | 6.3 (5.6-8.2) | 26.7 (18.7-29.6) | 441 MB |
| 3. + cache mount | 22.0 | 3.6 (3.5-7.9) | 7.8 (6.4-11.0) | 24.4 (16.0-26.7) | 408 MB |
| 4. + multi-stage | 20.1 | 4.9 (4.4-5.3) | 10.3 (8.6-11.5) | 22.5 (20.3-22.7) | 343 MB |
Pitfall: a cached RUN never goes stale on its own
The docs say the cache for RUN instructions isn't invalidated automatically between builds; to force a rerun you change an earlier layer or use --no-cache or --no-cache-filter. This is what bites with RUN apt-get update && apt-get install .... To avoid depending on package mirrors, I tested the mechanism with a RUN whose output changes on every execution:
FROM node:22-slim AS stamp
RUN date +%s > /stamp
b() { docker build -q -t stale "$@" . >/dev/null; docker run --rm stale cat /stamp; }
echo "build 1: $(b)"
sleep 3
echo "build 2 (plain rebuild): $(b)"
echo "build 3 (--no-cache-filter): $(b --no-cache-filter stamp)"
Output (Git Bash with MSYS_NO_PATHCONV=1 so /stamp isn't rewritten to a Windows path):
build 1: 1791708179
build 2 (plain rebuild): 1791708179
build 3 (--no-cache-filter): 1791708218
Build 2 ran three seconds later but returned the same timestamp: the command never re-ran. --no-cache-filter <stage> forced just that stage to rerun.
CI: exporting the cache (type=gha vs type=registry)
Hosted CI runners start empty, so the cache has to be exported somewhere.
GitHub Actions cache (type=gha). Untested on GitHub; I did not run this workflow. The snippet follows the gha backend docs (docker/build-push-action@v7, cache-from: type=gha, cache-to: type=gha,mode=max) and the docker/setup-buildx-action@v4 line from that action's README, both checked on 2026-10-11. The docs say scope defaults to buildkit and each build overwrites the previous cache unless you set a distinct scope such as the image name.
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: <registry>/<image>:latest
cache-from: type=gha,scope=<image>
cache-to: type=gha,scope=<image>,mode=max
Limits, per the GitHub docs: "By default, the limit is 10 GB per repository," and "GitHub will remove any cache entries that have not been accessed in over 7 days." The Docker docs add that the GitHub Actions cache API is subject to rate limiting.
Registry cache (type=registry). I ran this locally against a throwaway registry:2 container and a docker-container builder, using the multi-stage Dockerfile as Dockerfile.v4. To simulate a fresh runner I pruned the builder's cache before each build and pulled only from the registry. Per the registry docs, the ref must not be the location you push the image to, and if the --cache-from target doesn't exist the import fails but the build continues. registry.sh, run from the app directory:
#!/bin/bash
set -u
cp src/server.js /tmp/server.keep
docker rm -f reg >/dev/null 2>&1
docker run -d -p 5000:5000 --name reg registry:2 >/dev/null
docker buildx rm -f cb >/dev/null 2>&1
docker buildx create --name cb --driver docker-container --driver-opt network=host >/dev/null
docker buildx inspect cb --bootstrap >/dev/null
t() { local s=$(date +%s%N); out=$("$@" 2>&1); rc=$?; local e=$(date +%s%N)
echo "$(awk -v a=$s -v b=$e 'BEGIN{printf "%.1f",(b-a)/1e9}') s rc=$rc cached_steps=$(echo "$out" | grep -c ' CACHED')"; }
B="docker buildx --builder cb build --progress=plain -f Dockerfile.v4"
for mode in min max; do
ref=localhost:5000/cache-$mode
echo "--- mode=$mode"
docker buildx --builder cb prune -af >/dev/null
echo -n "export (cold build, cache-to): "; t $B --cache-to type=registry,ref=$ref,mode=$mode,image-manifest=true .
echo "cache size in registry: $(docker run --rm --network host curlimages/curl -s localhost:5000/v2/cache-$mode/manifests/latest -H 'Accept: application/vnd.oci.image.manifest.v1+json' | grep -o '"size": *[0-9]*' | awk -F: '{s+=$2} END{printf "%.0f MB", s/1e6}')"
docker buildx --builder cb prune -af >/dev/null
echo -n "no-change rebuild (fresh builder, cache-from): "; t $B --cache-from type=registry,ref=$ref .
docker buildx --builder cb prune -af >/dev/null
echo "// edit $mode" >> src/server.js
echo -n "source-edit rebuild (fresh builder, cache-from): "; t $B --cache-from type=registry,ref=$ref .
cp /tmp/server.keep src/server.js
done
docker rm -f reg >/dev/null; docker buildx rm -f cb >/dev/null
Output (registry.txt). "Cache size" is the sum of layer sizes in the cache manifest, so approximate:
| Exported with | Cache size | Export build | No-change rebuild | Source-edit rebuild | Cached steps on source edit |
|---|---|---|---|---|---|
mode=min (default) |
81 MB | 44.1 s | 7.1 s | 38.4 s | 5 |
mode=max |
97 MB | 69.9 s | 7.3 s | 28.0 s | 7 |
One run each, on a local registry with no network latency, so the times can't separate the modes. The cached-step counts can: max restored 7 steps on the source edit against 5 for min. The registry docs say max can cache multi-stage builds "instead of only the final stage." When to choose max: when your expensive layers (the dependency install) sit in a non-final stage of a multi-stage Dockerfile, as in this one. For a single-stage Dockerfile I'd stay on the default min.
Do you need a paid Docker layer caching service?
CI vendors sell this as a feature; Harness (docs), Nx Cloud (docs) and RazorOps (docs) each have a page on it. I did not test any of them or compare prices, so check each vendor's terms yourself.
The decision rule I'd use:
- If your Dockerfile copies the source before installing, fix that first. It's free and was the biggest effect in my runs.
- Then export the cache with
type=registryortype=gha. - Consider a paid service only if the free exporters hit a limit you can name: the 10 GB GitHub cache cap, the 7-day eviction on a rarely built project, or cache restore time exceeding the build time it saves.
What to do next
- Reorder your Dockerfile: manifest and lockfile
COPY, install, thenCOPY . .. Time a source edit before and after. - Add a
.dockerignorewithnode_modules,.git, build output and.env*. Check the "transferring context" line in--progress=plainoutput. - Add
--mount=type=cacheto your package-manager step and time that step alone. - Split build and runtime into stages if image size matters.
- In CI, add a
cache-towith a per-imagescopeor a separate cacheref, then compare a cold run and a second run in your own pipeline.
Keep reading
Complete Solution: Scaling a Node.js Application with Kubernetes and Docker
29 min · 876 views
Data ScienceMastering CI/CD Pipelines with Jenkins and Docker: A Deep Dive into Automated Deployment and Testing
14 min · 857 views
DevOpsDocker Layer Caching Strategies That Actually Work
23 min · 231 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In