Lean Docker Images: Multi-Stage Builds and Layer Caching

In the last post we ran a Node API with Compose using a stock node image and a bind mount. That is great for development, but it is not how you ship. To deploy, you build an image: a self-contained artifact with your code and its dependencies baked in. The catch is that the obvious way to write that Dockerfile produces an image that is enormous and slow to rebuild.
In this post we build the same API two ways. First the naive version that just works, then an optimized multi-stage version, and we measure the difference. Every number below was captured on a real Docker Engine.
Tip
FROM node:22 image here was 1.62GB. The multi-stage, Alpine-based version was 233MB, about 7 times smaller.Most of the size is the base image. node:22 is 1.62GB; node:22-alpine is 227MB.Order your Dockerfile for the cache. Copy package.json and the lockfile first, install, then copy the rest. A code change should not reinstall your dependencies.Multi-stage builds let you install and build in one stage and ship only the result, so build tools never reach the final image.Add a `.dockerignore` so junk like node_modules and .git never enters the build.Info
Prerequisites
- Docker installed and running. See Install Docker on macOS, Windows (WSL2), and Linux if you need it.
- The app from Docker Compose: Run a Multi-Service Stack. We reuse its
server.jsandpackage.json. You do not need Postgres or Redis running to build the image; we are packaging the app, not starting it.
Attempt 1: the naive Dockerfile
Here is the version almost everyone writes first. It works, and that is the problem: it hides how much room there is to improve.
1# The naive way: everything works, nothing is optimized.2FROM node:223WORKDIR /app4COPY . .5RUN npm install6EXPOSE 30007CMD ["node", "server.js"]Build it and check the size:
1docker build -f Dockerfile.naive -t demo-api:naive .2docker images demo-api --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"1REPOSITORY:TAG SIZE2demo-api:naive 1.62GB1.62GB for a tiny API. Two problems are baked in:
- The base image is huge.
FROM node:22pulls the full Debian-based Node image, which is 1.62GB on its own. Our app adds almost nothing on top, so the base is essentially the whole image. - The layer order defeats the cache.
COPY . .copies your source beforenpm installruns. Docker caches each instruction, but a layer's cache is invalid the moment any input changes. Since your source changes constantly, thatCOPYbusts on every edit, which forcesnpm installto run again every single build.
Attempt 2: the optimized multi-stage Dockerfile
Now the version you actually want. It fixes both problems: a small base, and an order that keeps dependencies cached.
1# The optimized way: multi-stage build, slim base, production deps only,2# and cache-friendly ordering so code changes do not reinstall dependencies.3 4# Stage 1: install only production dependencies.5FROM node:22-alpine AS deps6WORKDIR /app7COPY package.json package-lock.json ./8RUN npm ci --omit=dev9 10# Stage 2: the final runtime image. It carries only the app and its11# production node_modules, on a small Alpine base.12FROM node:22-alpine13WORKDIR /app14ENV NODE_ENV=production15COPY --from=deps /app/node_modules ./node_modules16COPY server.js ./17EXPOSE 300018CMD ["node", "server.js"]And a .dockerignore so the build context stays clean:
1node_modules2npm-debug.log3.git4.gitignore5.env6Dockerfile*7.dockerignoreBuild it and compare:
1docker build -f Dockerfile -t demo-api:slim .2docker images demo-api --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"1REPOSITORY:TAG SIZE2demo-api:slim 233MB3demo-api:naive 1.62GB233MB versus 1.62GB. Same app, about one seventh the size. Three changes did the work.
1. A smaller base image
node:22-alpine is 227MB against node:22 at 1.62GB. Alpine is a minimal Linux distribution, so you drop a whole Debian userland you were not using. For most Node apps, Alpine is all you need. When you want to go even smaller and more locked down, distroless images are the next step, but Alpine is the easy, safe default.
2. Multi-stage: build in one stage, ship another
The file has two FROM lines, so two stages. The deps stage installs dependencies. The final stage starts fresh from a clean Alpine and copies in only what it needs with COPY --from=deps. Anything that existed only in the build stage never reaches the final image: caches, temporary files, dev tooling. Our app is simple, but this is the pattern that keeps compilers and build caches out of production images for TypeScript, Go, and everything else.
3. Production dependencies only
npm ci --omit=dev installs just the runtime dependencies and skips devDependencies like test runners and linters. (This demo only declares two runtime dependencies, so --omit=dev changes nothing here; it is the habit that pays off the moment you add real devDependencies.) Combined with ENV NODE_ENV=production, the final image carries only what it needs to run. npm ci also requires a committed package-lock.json, which makes the install reproducible: the same versions every time, on every machine.
Prove the cache works
Size is the headline, but the day-to-day win is rebuild speed. Make a one-line change and rebuild both images:
1echo "// a small change" >> server.js2docker build --progress=plain -f Dockerfile.naive -t demo-api:naive . # naive3docker build --progress=plain -f Dockerfile -t demo-api:slim . # optimizedOn the naive build, the COPY . . layer sees the changed file, so its cache is invalid and everything after it re-runs, including npm install:
1#7 [3/4] COPY . .2#7 DONE 0.1s3#8 [4/4] RUN npm install4#8 DONE 1.6sOn the optimized build, the code change only affects the final COPY server.js. The dependency stage did not change, so BuildKit reuses it:
1#6 [deps 3/4] COPY package.json package-lock.json ./2#6 CACHED3#8 [deps 4/4] RUN npm ci --omit=dev4#8 CACHED5#9 [stage-1 3/4] COPY --from=deps /app/node_modules ./node_modules6#9 CACHED7#10 [stage-1 4/4] COPY server.js ./8#10 DONE 0.2sThat CACHED on npm ci is the whole point. Your dependencies are installed once and reused until package.json or the lockfile actually changes.
Info
Look at the layers
docker history shows what each layer contributes to the final image:
1docker history demo-api:slim1SIZE CREATED BY20B CMD ["node" "server.js"]30B EXPOSE [3000/tcp]44.1kB COPY server.js ./55.71MB COPY /app/node_modules ./node_modules60B ENV NODE_ENV=production70B WORKDIR /app8... (node:22-alpine base layers, ~227MB)Your application is the top few layers: a 4.1kB source file and a 5.71MB node_modules. Everything else is the Alpine base. There is nothing left to trim without changing the base image itself.
Common gotchas
npm ci fails with "no package-lock.json found"
npm ci requires a committed lockfile. Generate one with npm install (or npm install --package-lock-only) and commit package-lock.json. This is a feature: it is what makes the build reproducible.
Alpine build fails on a native module
Some npm packages compile native code and expect the GNU C library, while Alpine uses musl. If a dependency fails to build on Alpine, either add the build toolchain in the deps stage (apk add --no-cache python3 make g++) or switch the base to node:22-slim, a smaller Debian image that keeps glibc.
The image is still huge
Check three things: are you on an Alpine or slim base, are you running npm ci --omit=dev rather than a full install, and do you have a .dockerignore so node_modules and .git are not copied in. Missing any one of these puts the weight back.
Reordering did not help
Make sure COPY package.json package-lock.json ./ comes before the source COPY, and that only those two files are in that first copy. If you COPY . . before installing, you are back to busting the cache on every change.
Where to go next
You now have a lean, cache-friendly image. The next step is getting it off your machine so you (or your CI) can pull it anywhere.
- Next in this series: Docker Images and Registries, where we tag the image and push it to Docker Hub and GitHub Container Registry, then pull it back and inspect what is really inside.
Verified on 2026-09-10 on a real Ubuntu 24.04.5 LTS system (arm64, an OrbStack VM on Apple Silicon; on amd64 the exact byte counts differ slightly) with Docker Engine 29.8.0 (BuildKit). Both images were built from the same app: the naive `node:22` image measured 1.62GB and the multi-stage `node:22-alpine` image 233MB (`docker images`), base images node:22 (1.62GB) and node:22-alpine (227MB). After a one-line change to server.js, the naive rebuild re-ran `RUN npm install` while the optimized rebuild showed `CACHED` on the deps stage (`COPY package.json package-lock.json` and `RUN npm ci --omit=dev`), re-running only `COPY server.js`. The `docker history` layer sizes are from that build.
Join the discussion on Lean Docker Images: Multi-Stage Builds and Layer Caching
Likes, comments, and replies are available for authenticated readers with verified email addresses.


