Docker Image Caching and Layer Ordering Tricks
Speed up image rebuilds by ordering instructions for cache hits and using BuildKit cache mounts: dependencies first, code last, and caches that persist.
Netbay Developer Relations
Netbay Engineering
On this page
Rebuild speed is a cost you pay on every commit, and most teams pay it unnecessarily. Docker's build cache lets each layer be reused when neither its instruction nor its inputs changed, which turns an eight-minute rebuild into a forty-second one for exactly the files that matter. The catch is that the cache is strictly ordered: the moment one instruction changes, everything after it rebuilds. That makes instruction ordering the single highest-leverage skill in image authoring. This guide explains the cache's rules, shows the ordering patterns that exploit them, and introduces BuildKit cache mounts for the dependency downloads most rebuilds are really waiting on.
How the Layer Cache Actually Decides
During a build, every instruction is checked against the cache before it runs. Docker compares the instruction itself and the files it used; when both match, the log prints CACHED and the layer is reused without executing. The decision is positional: the comparison restarts at the first mismatch, and everything below that point runs fresh. That is why "it cached on my machine" and "it rebuilt the world on CI" are the same file with one byte of difference in an early COPY — build context differences count as input changes, so a .dockerignore that silently lets logs, caches, or metadata into the context invalidates everything after the copy.
Order Dependencies Before Source
The pattern is monotonic in practice: put instructions in descending order of how often they change, and the cache pays out on every rebuild. The base image and package manifests change rarely; source code changes constantly. A Node project demonstrates the whole shape:
FROM node:22-alpine
WORKDIR /app
# Changes rarely -> nearly always a cache hit
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Changes on every commit -> the only thing that rebuilds
COPY . .
CMD ["node", "src/server.js"]The npm ci step re-runs only when the lockfile changes, which is exactly the slow case you want to avoid redoing. The same ordering applies everywhere: copy requirements.txt before the rest of a Python context, go.mod and go.sum before the source tree, Gemfile.lock before the app. Dependencies first, everything else last.
BuildKit Cache Mounts: Caches That Survive
Cache reuse across rebuilds is powerful but limited: a layer is reused only if its instruction is identical, so an apt-get update that changes daily still burns minutes daily. BuildKit solves the deeper problem with RUN --mount=type=cache, which mounts a persistent, content-addressed cache into a step and stores it in the engine between builds. The cache is not part of the image — it exists only at build time — which means you get persistent package caches without shipping them.
# syntax=docker/dockerfile:1
FROM golang:1.23-alpine
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/server .Now the module download and the build cache persist across builds, so a rebuild preserves everything already fetched even when go.mod ordering shifts around COPY layers. The same trick works for npm (target=/root/.npm or /usr/local/share/.cache) and for apt (target=/var/cache/apt with an apt-key cleanup step). The syntax line at the top just opts the file into the current Dockerfile features.
Avoid Cache-Killing Instructions
Two instructions are the classic rebuild-serializers. COPY or ADD of a directory that contains junk (node_modules, .git, build output) invalidates its layer every time the junk changes, so build it into the .dockerignore, not into the image. And any ENV or ARG used early — especially timestamps or random build ids — changes the effective instruction and invalidates the whole chain below it; keep volatile arguments at the end of the file, or pass them to the specific RUN that needs them.
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# volatile value last: cannot invalidate the install layers
ARG COMMIT_SHA=local
ENV COMMIT_SHA=$COMMIT_SHA
COPY . .
CMD ["node", "src/server.js"]Observe that ENV sits after npm ci, so even though every build passes a fresh commit hash, the expensive install layers still hit the cache. If a build needs a frequently-changing value inside an early step, isolate that step into its own layer so only it and everything after re-execute.
Inspecting What the Cache Holds
BuildKit keeps cache statistics under docker system df, and failed or surprising rebuilds are easier to read with plain progress output:
docker build --progress=plain -t my-app:dev .
docker system df
docker buildx prune --helpThe plain progress output shows, line by line, which layers were CACHED and which re-ran, so you can see a rebuild regressing from three cached steps to none on the same host. docker system df breaks out the build cache's size, and docker buildx prune is the knob to reclaim space without touching the image store — run it with --filter until=24h to sweep only stale cache.
Takeaway
The build cache rewards two habits: order instructions from stable to volatile so only the tail ever rebuilds, and move dependency downloads into persistent BuildKit cache mounts so even the unstable steps cheapen over time. Both are content-organization decisions, not extra tooling — the same files, reordered, rebuild faster on the same host. On a Netbay VPS you control the whole machine, so you can tune the cache, prune it, and watch rebuild times shrink from the same shell — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance