What happened
On one of our projects the production image had grown to 1.2GB for a fairly ordinary Node service. Deploys were slow because every node had to pull that image, and security scans kept listing packages that the running app never touches.
When we looked inside, the image still carried the compilers, build tools and dev dependencies that we only needed while building. The final image had effectively been built on top of the same fat environment we used to compile the app.
Why it happens
Every instruction in a Dockerfile adds to the image, and the final image contains everything in the stage it was built from. If you install toolchains and dev dependencies in that stage, they stay in the image even if a later step builds the app and you never use them again.
Multi-stage builds solve this by letting you use one stage to build and a separate, smaller stage to run. Only the files you explicitly copy across with COPY --from end up in the final image. The build stage and everything in it is discarded.
What we do now
We now build in one stage, prune dev dependencies there, and start the runtime stage from a slim base image. The runtime stage copies only the built output, the package manifest and the production node_modules. That took the image from 1.2GB to 340MB, which is a 71% reduction.
A nice side effect is the smaller attack surface. Fewer packages in the image means fewer things to patch and fewer scanner findings that have nothing to do with our code.
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]Quick checklist
- 1Name the build stage with AS build and do the install and compile there.
- 2Run npm prune --omit=dev (or install production dependencies separately) at the end of the build stage.
- 3Start a new FROM with a slim runtime image.
- 4Use COPY --from=build for only the output, manifest and production node_modules.
- 5Compare sizes with docker image ls before and after.
Rule of thumb
Build in one stage, run in another, and copy across only what the running app needs.