v5.0
live available
practices ranked 08 Sept 2026

Eight Dockerfile habits, ranked by what each one cost on one measured build

Same Node app, eight bad habits, every figure measured on one machine: what each adds to the image, to the wire, and to a rebuild after a one-line edit.

By Purvansh Parmar Checked 2026-09-08 Read 6 min Entries 8

Docker advice is usually given without a number attached. So I built the same three-dependency Node service eight different ways this morning, measured every version on disk and on the wire, and timed what a one-line source edit costs in each. The spread is not subtle. Between the worst and the best build of identical application code there was 905 MB of disk, 329 MB of download per pull, and an 88x difference in how long a rebuild took after changing one line. Ranked below by what each habit actually cost. One of them turned out to cost more than doing nothing at all.

How this was checkedI built the same three-dependency Node service a dozen ways on one 8-core box this morning, pushed every result into a local registry so I could read the compressed layer sizes instead of guessing them, and timed a warm rebuild after editing one line of source. Every number here came off that run. The one that surprised me: the cleanup layer left the image 217 kB larger than skipping the cleanup entirely, and cost 32.8s of build time to do it.

What I compared on

01 Fat base image

FROM node:22 when nothing at runtime needs a compiler, git, or python3.

Wins
It is the right call when your install genuinely compiles native modules, or when something in production shells out to git or make. node:22 ships build-essential, python3 and git already, so you skip an apt layer and the 68s it cost me to add one.
Loses
You pay 409.5 MB of download on every pull for a toolchain that runs once at build time. Multiply taht by every CI job and every node in a rollout. The same app on node:22-slim came to 305 MB on disk against 1.21 GB, and node:22-alpine landed at 172 MB.
1.13 GB vs 227.3 MB on disk409.5 MB vs 80.8 MB per pull905 MB removed by one word

02 COPY before install

COPY . . sitting above RUN npm ci, so any source edit invalidates the install layer.

Wins
For a one-shot image you build once and never touch, the ordering is irrelevant and the shorter Dockerfile is easier to read. It is also the only version that works if your install step reads files outside package.json, which some postinstall scripts do.
Loses
I changed one character in src/index.js. The rebuild took 26.4s, against 25.5s for the cold build from nothing: the cache bought exactly zero. With package.json copied first and src copied last, the same edit rebuilt in 0.3s. That is 88x, and you pay it on every save.
26.4s vs 0.3s88xcold build was 25.5s, so caching saved nothing

03 No .dockerignore

No exclusions, so the whole working tree is uploaded to the builder before anything runs.

Wins
Fine on a repo that has no local node_modules, no test artifacts and a shallow .git. My src plus manifests came to 183.14 kB, and at that size the file is pure ceremony.
Loses
BuildKit reported 118.21 MB of context transferred, every build, against 183.14 kB with five lines of exclusions. The COPY . . layer alone was 117 MB. Worse, the host's node_modules ships inside the image and shadows the one you installed, so the binaries you run were compiled for the wrong machine.
118.21 MB vs 183.14 kB of context117 MB COPY layer42.5 MB larger image

04 apt cache left in the layer

apt-get install with recommends on and /var/lib/apt/lists never removed.

Wins
Keeping the lists means a later apt-get install in the same image does not need another update, which is worth having in a base image other people build on top of. Recommends also spares you a missing-dependency hunt.
Loses
Adding --no-install-recommends and one rm -rf of the lists took the image from 590.4 MB to 518.0 MB, and the pull from 216.2 MB to 183.8 MB. Two flags, 72.4 MB. There is no case where you want apt metadata inside a production runtime.
590.4 MB vs 518.0 MB on disk216.2 MB vs 183.8 MB per pull72.4 MB for two flags

05 Dev dependencies in the runtime image

npm ci with no --omit=dev, so typescript, jest and esbuild ride into production.

Wins
You need them present if the container runs your test suite, which is a legitimate CI pattern, and it keeps one image for both jobs. Installing everything also avoids the trap where a package is in the wrong section of package.json and only fails in production.
Loses
typescript, esbuild and jest cost 69.2 MB on disk and 27.0 MB on every pull, to sit in a directory nothing in production reads. On this app they make the image 29 percent larger for code that never executes.
305.0 MB vs 235.8 MB on disk27.0 MB per pull29 percent larger image

06 Single stage build

One FROM, so whatever the build needed stays in the image that ships.

Wins
When your production dependencies are small, multi-stage buys almost nothing and costs you a Dockerfile that is twice as long and harder to debug. This is the case for most plain Node services.
Loses
It hides the cost of a heavy build. Bundling with esbuild in a build stage and copying one file into a fresh node:22-slim gave 228.5 MB against 235.8 MB, so 7.3 MB, which is the least valuable fix on this list. On a Rust or a webpack build with a 400 MB toolchain the same change is worth a hundred times that.
228.5 MB vs 235.8 MB7.3 MB saved81.1 MB vs 84.2 MB per pull

07 No npm cache mount

Plain RUN npm ci, so a lockfile change redownloads every tarball from the registry.

Wins
It needs no syntax directive, works on any builder including old ones, and keeps the build hermetic: what you get is exactly what the lockfile says, with no shared state on the machine to explain a difference between two runs.
Loses
It also leaves npm's own cache in the layer, which cost 2.2 MB on disk and 1.6 MB on the wire here. The build-time win is smaller than the internet claims: adding a dependency and rebuilding took 10.2s without the cache mount and 9.7s with it. On a fast link the mount saves half a second.
10.2s vs 9.7s0.5s saved2.2 MB left in the layer

08 Cleanup in a later layer

RUN npm prune and rm -rf of caches, placed in a RUN after the one that created them.

Wins
The only thing it buys you is a smaller running container filesystem, because the whiteout hides the files from anything reading the merged view at runtime. If something in production walks node_modules and chokes on a dev package, this hides it.
Loses
It does not shrink the image, and I can put a number on that: 305,017,068 bytes without the prune step, 305,234,403 bytes with it. The cleanup made the image 217 kB bigger and took 32.8s. The earlier layer still carries every byte, and the deletion is itself a layer.
217 kB larger32.8s of build time0 bytes reclaimed

Side by side

HabitDiskPer pullWhat it cost
Fat base image1.21 GB439.9 MB905 MB disk, 329 MB per pull
COPY before installsamesame26.4s vs 0.3s on a one-line edit
No .dockerignore+42.5 MBn/a118.21 MB of context, every build
apt cache in the layer590.4 MB216.2 MB72.4 MB for two flags
Dev deps in runtime305.0 MB111.2 MB69.2 MB disk, 27.0 MB per pull
Single stage build235.8 MB84.2 MB7.3 MB
No npm cache mount+2.2 MB+1.6 MB0.5s on a dependency change
Cleanup in a later layer+217 kB+0.2 MBworse than doing nothing

Verdict

Change the base image first. Going from node:22 to node:22-slim is one word on one line, it took 905 MB off the disk and 329 MB off every pull, and unlike a reordered Dockerfile it cannot quietly break your install. A fat base image is the only entry here that returns that much for that little. Reorder second: moving the install above COPY . . took a one-line rebuild from 26.4s to 0.3s, and that is the bill you pay all day. Everything under those two is worth roughly 150 MB put together. The cleanup layer is worth less than nothing, so delete it.

Sources