← Back to GuidesGUIDESelf-Hosting & Homelab

Lean Docker Images: Multi-Stage Builds and Layer Caching

about 14 hours ago
❤️ 0 likes
💬 0 comments
dockerself-hostinglinux
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

Key takeaways A naive 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

Get the code. Both Dockerfiles and the app are in the docker-foundations repo, under `04-dockerfiles/`. Clone it to follow along.

Prerequisites

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.

Dockerfile
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:

Bash
1docker build -f Dockerfile.naive -t demo-api:naive .2docker images demo-api --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
Plain Text
1REPOSITORY:TAG   SIZE2demo-api:naive   1.62GB

1.62GB for a tiny API. Two problems are baked in:

  1. The base image is huge. FROM node:22 pulls 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.
  2. The layer order defeats the cache. COPY . . copies your source before npm install runs. Docker caches each instruction, but a layer's cache is invalid the moment any input changes. Since your source changes constantly, that COPY busts on every edit, which forces npm install to 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.

Dockerfile
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:

Plain Text
1node_modules2npm-debug.log3.git4.gitignore5.env6Dockerfile*7.dockerignore

Build it and compare:

Bash
1docker build -f Dockerfile -t demo-api:slim .2docker images demo-api --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
Plain Text
1REPOSITORY:TAG   SIZE2demo-api:slim    233MB3demo-api:naive   1.62GB

233MB 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:

Bash
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 .           # optimized

On the naive build, the COPY . . layer sees the changed file, so its cache is invalid and everything after it re-runs, including npm install:

Plain Text
1#7 [3/4] COPY . .2#7 DONE 0.1s3#8 [4/4] RUN npm install4#8 DONE 1.6s

On the optimized build, the code change only affects the final COPY server.js. The dependency stage did not change, so BuildKit reuses it:

Plain Text
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.2s

That CACHED on npm ci is the whole point. Your dependencies are installed once and reused until package.json or the lockfile actually changes.

Info

Be honest about the numbers. Because this demo app depends on just two small packages, the rebuild time difference here is only about a second. The payoff scales with your dependency tree: on a real app with hundreds of packages, a cached dependency layer turns a minute-long reinstall into an instant rebuild. The image-size win, on the other hand, is large no matter what.

Look at the layers

docker history shows what each layer contributes to the final image:

Bash
1docker history demo-api:slim
Plain Text
1SIZE     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.

Comments (0)

Loading discussion...

Related guides