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
- one 8-core Ubuntu 22.04 box, Docker 29.8.0, BuildKit through buildx 0.37, npm 10 on Node 22
- disk size is docker image inspect .Size; wire size is the sum of compressed layer sizes read back from a manifest in a local registry:2, which is what a pull actually downloads
- rebuild time is a warm build after changing one line in src/index.js, with the builder cache left intact
- ranked by cost measured here, weighting a per-edit penalty above a one-off megabyte because you pay the per-edit one a dozen times a day
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
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
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
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
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
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
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
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
| Habit | Disk | Per pull | What it cost |
| Fat base image | 1.21 GB | 439.9 MB | 905 MB disk, 329 MB per pull |
| COPY before install | same | same | 26.4s vs 0.3s on a one-line edit |
| No .dockerignore | +42.5 MB | n/a | 118.21 MB of context, every build |
| apt cache in the layer | 590.4 MB | 216.2 MB | 72.4 MB for two flags |
| Dev deps in runtime | 305.0 MB | 111.2 MB | 69.2 MB disk, 27.0 MB per pull |
| Single stage build | 235.8 MB | 84.2 MB | 7.3 MB |
| No npm cache mount | +2.2 MB | +1.6 MB | 0.5s on a dependency change |
| Cleanup in a later layer | +217 kB | +0.2 MB | worse 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