Skip to main content
CodeOath
← All posts

Docker105 min total · 19 parts

Docker Fundamentals: Images, Containers, and Writing a Good Dockerfile

Part 8 of 19 · ~3 min

Multi-Stage Builds

Three weeks in, you go to actually deploy web to a real server for the first time — a staging box with a modest, metered connection — and the push takes almost four minutes. docker images explains why: snapledger-web is 1.2 gigabytes. Nobody had looked, because on local machines with everything already cached, a big image doesn't feel slow.

Where it went: the full Node toolchain, every dev dependency, the entire npm cache, and the source files that only matter at build time, all baked permanently into the one image you're both building and shipping.

A multi-stage build is how you get both jobs done without shipping the first one along with the second. A single Dockerfile can contain several FROM lines instead of just one, and only the last stage in the file is what actually gets shipped:

# Stage 1: build — full toolchain, dev dependencies, everything npm needs
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: run — only the compiled output and its runtime dependencies ship
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

COPY --from=build is doing something quite selective: naming an earlier stage and picking out exactly the paths you list, leaving every other byte that stage produced behind. Whatever built dist and node_modules — the compiler, the whole devDependencies tree, every intermediate artifact the build step generated, the much larger image that stage started from — simply never gets copied over, so none of it becomes part of what actually ships. snapledger-web drops from 1.2 gigabytes to 74 megabytes, and the staging push finishes in about six seconds.

Everyone's thrilled, and this is genuinely a win — but notice the two FROM lines: the build stage is node:20 and the final stage is node:20-alpine, a different base image entirely. npm ci in stage one links anything with compiled native code against stage one's C library. Whether that mismatch matters depends on whether anything in node_modules actually has compiled native code in it — and right now, nobody's checked. File that away; it's going to matter in three chapters.

Nothing says a stage has to be what ships, either. When CI keeps needing its own slightly different Dockerfile just to run the test suite, you add one more named stage instead:

FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM build AS test
RUN npm run lint && npm test

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Left to its defaults, docker build . walks straight to whichever stage is written last — the small production one, here — and only that stage gets built and tagged, so none of this adds a second of cost to a normal deploy. CI instead runs docker build --target test ., which builds everything up through the test stage and stops there, failing the whole build the moment npm test exits non-zero. One Dockerfile now answers two different questions — "does this pass CI" and "what actually ships" — without maintaining a second file that can quietly drift out of sync with the first.