What a bad Dockerfile costs you
| |
Four lines, and every one of them costs you something. docker build re-runs npm install on every code change, because COPY . . breaks the layer cache before the install step ever gets a chance to be reused. The final image carries the full Debian base, the entire node_modules tree including dev dependencies, and whatever build tools npm install pulled in to compile native modules. Nothing is discarded. The container runs as root, because nothing tells it not to, and node:latest means the base image can change under you between one build and the next, with no record of what you actually shipped.
None of this shows up as an error. The image builds, the container starts, the app responds. The cost arrives later: a five-minute CI build because every step reruns from scratch, and a 1.1 GB image for an app whose own code is a few hundred kilobytes. Each fix below is small. Together they change what a Dockerfile costs you every day.
How Docker layer caching decides what to rebuild
Docker builds an image one instruction at a time and caches the result of each one as a layer. On the next build it walks the Dockerfile again and reuses a cached layer as long as the instruction is identical and the layer above it in the chain hasn’t changed. The moment one instruction misses the cache, every instruction after it reruns too. Cache hits only extend a chain from the top. If images, layers and containers are still fuzzy, what Docker is and how containers work covers the ground this article assumes.
COPY and ADD invalidate on content, not just on the instruction text: if any file being copied changed, that layer’s cache is gone, and so is everything below it. COPY . . copies the entire build context, so it invalidates on any change anywhere in the project, down to a reworded comment in a file that has nothing to do with dependencies. Put RUN npm install right after that COPY and it reruns on every single build.
How to order Dockerfile instructions for cache hits
Copy only what an instruction needs, in the order things actually change: dependency manifests rarely, source code constantly.
| |
package.json and package-lock.json change when you add or upgrade a dependency. RUN npm ci now caches against those two files and nothing else. Edit a source file and rebuild: Docker reuses the cached install layer and reruns only the final COPY and what follows it. The slowest step in the build runs only when it has to.
npm ci instead of npm install is deliberate. It installs exactly what’s in the lockfile and fails if the lockfile and package.json disagree, rather than quietly resolving new versions into a build you expected to be reproducible.
The same ordering applies outside Node: a Python project copies requirements.txt and runs pip install before the rest of the source, a Go project copies go.mod/go.sum and runs go mod download first.
How multi-stage builds shrink the final image
Reordering fixes the cache. It does nothing about an image that still ships dev dependencies, build tools, and whatever npm ci downloaded to compile native modules, none of which the app needs to run. A multi-stage build separates what it takes to build the app from what it takes to run it.
| |
Two FROM lines, two stages. The first, named build, installs every dependency (including dev-only ones like a bundler or TypeScript) and produces dist/. The second starts fresh from the same base image, installs production dependencies only, and pulls in one directory from the first stage with COPY --from=build. The TypeScript compiler, the source .ts files, npm’s cache: none of it reaches the final image, because the build stage is thrown away once the build finishes.
The size difference is the point. node:latest, the full Debian-based image, runs close to 1.1 GB before you add a single dependency. node:24-slim is under 300 MB, and node:24-alpine under 200 MB if your dependencies don’t need glibc. Multiply that across every service you run and every CI runner that pulls the image, and the gap is real storage and real minutes.
The effect is bigger for a compiled language, where the binary needs nothing but itself.
| |
No shell, no package manager, no Go toolchain in the final image: only the static binary plus the CA certificates and timezone data that distroless/static provides. An attacker who gets execution in that container has no sh to run and no apt to install one.
What to put in .dockerignore
COPY . . sends the whole build context to the Docker daemon before the build starts, and that context includes files you never meant to ship: .git, a local node_modules, .env files with real credentials, editor config. A .dockerignore next to the Dockerfile excludes them, using the same pattern syntax as .gitignore.
| |
Excluding node_modules is about more than size. If a local install on your machine has native modules compiled for macOS and arm64, COPY . . ships those binaries into a Linux container where they will not load. Let the container install its own dependencies.
How to run a container as a non-root user
Nothing in a plain Dockerfile stops a container from running as root, so by default it does. If an attacker gets code execution inside it, through a dependency vulnerability or a deserialization bug, root in the container is one kernel bug or one bad mount away from root on the host. Most official images already ship a user you can switch to.
| |
The node image creates a node user, so USER node is the only line you add. When a base image ships no such user, create one before switching:
| |
The --chown matters as much as the USER. Without it the files land owned by root while the process reading them runs as app, and anything the app writes at runtime fails with a permissions error you discover in production. The same applies to a Docker volume mounted into the container: its contents have to be writable by the UID you switched to, or the first write dies on startup.
How to pin the base image tag or digest
FROM node:latest resolves to whatever latest points at on the day you build. Rebuild the same Dockerfile next month and you can land on a different major version, a different Debian release, different system packages, with no diff in your repository showing what moved. Pin the version:
| |
For a build that has to be reproducible byte for byte, pin the digest as well. That ties the build to one immutable image no matter what happens to the tag:
| |
Get the digest with docker pull node:24.9.0-slim followed by docker inspect --format='{{index .RepoDigests 0}}' node:24.9.0-slim. Tag pinning covers most projects. Digest pinning is for pipelines where “what exactly did we ship” has to be answerable months later.
When to combine RUN instructions in one layer
Each RUN produces a layer, and a layer only ever grows. Deleting a file in a later layer doesn’t shrink the image, it hides the file behind a whiteout marker while the earlier layer still carries the bytes. Which is why splitting install and cleanup across two RUN instructions saves nothing:
| |
Put the cleanup in the same layer as the install:
| |
This applies to installs that leave files behind: package manager caches, downloaded archives, build artifacts you already copied elsewhere. It is not an argument for merging every RUN in the file. A handful of readable layers debug better than one 400-character line, and layer count on its own is not what’s costing you size.
When these practices aren’t worth it
A one-off script you run locally with docker build -t scratch . && docker run --rm scratch needs neither a multi-stage build nor a pinned digest. The ceremony costs more than the risk it avoids.
Alpine’s smaller footprint comes from musl libc instead of glibc, which breaks native Node and Python modules compiled against glibc. Segfaults or missing-symbol errors after switching to -alpine are usually that. Use -slim when you’re not sure.
And pinning is a choice with a cost: an unpinned base image picks up security patches on the next docker build --pull without you doing anything. If you want that, take it deliberately, knowing a build can start behaving differently for reasons that aren’t in your diff. Leaving the tag off by accident is not the same decision.
How to measure image size and build time
Build both versions of the same app and compare:
| |
docker images shows the size gap. docker history shows which instruction produced which layer and how big it is, which is the fastest way to find the one RUN still dragging in something it shouldn’t. None of this changes if your app is built by Docker Compose: a service with build: . uses the same Dockerfile, the same cache and the same build context, so docker compose build gets the same wins.
Inherited a Dockerfile that predates all of this? Build it once as it stands, run docker history, and fix whichever layer is the biggest surprise. That single layer is usually most of the gap.