After choosing a base image, the next design question is how to separate build-time tools from runtime dependencies inside one Dockerfile. Beginners often stuff compilers, package managers, and production binaries into a single image. That approach is heavy and hard to maintain. Multi-stage builds keep the build steps and runtime cleanly separated, which leads to lighter, safer images. This post focuses on a Node + nginx example, teaches the recommended three-stage pattern first, then shows where a simpler two-stage version still fits.
How this post flows
- Why multi-stage builds matter
- Basic structure: builder vs. runner
- Writing a multi-stage Dockerfile for a Node app
- Compare cache behavior and image size
- Real-world checkpoints and extension ideas
- How to apply the pattern elsewhere
Terms introduced here
- Stage: A build block defined by one
FROMinstruction inside a Dockerfile. - Artifact: The bundle of files produced in one stage and copied into the next stage.
Reading card
- Estimated time: 20 minutes
- Prereqs: Dockerfile fundamentals and some Node.js build experience
- After reading: you can design a multi-stage flow and explain
COPY --frompatterns.
Why multi-stage builds matter
Single-stage Dockerfiles mix build tools and runtime binaries. Consider a Node app: npm ci, npm run build, and the nginx runtime all reside in one container. The final image grows large, and now you must patch build tools even though production only needs the built assets.
That problem becomes easier to feel with one concrete scenario. Imagine you update a CSS file and rebuild the image. In a single-stage Dockerfile, Docker may rerun dependency installation, rebuild the app, and keep the entire Node toolchain in the final image even though nginx only needs static files. A multi-stage Dockerfile splits those responsibilities so the expensive work stays isolated and the runtime stays small.
Multi-stage builds fix that with one simple idea:
- Use everything you need in the build stage, then pass only the artifacts forward.
- Keep the runtime image minimal, containing only what the app needs to run.
- Assign clear responsibilities per stage, which makes caching decisions easier.
Recommended structure: deps, builder, and runner
For Node-based frontend builds, the most useful beginner pattern has three stages.
deps: install dependencies from the lockfilebuilder: copy source files and produce the final static assetsrunner: start from a fresh nginx image and copy in only the built files
This separation helps because npm ci is usually the slowest step. If you isolate it in its own stage, Docker can reuse that cached dependency output when only the source code changes.
When only source files change:
- deps stage: usually CACHED
- builder stage: rebuilds
- runner stage: rebuilds with the new static assets
When package.json or package-lock.json changes:
- deps stage: rebuilds
- builder stage: rebuilds on top of new dependencies
- runner stage: rebuilds with the new output
Basic structure: builder vs. runner
The smallest multi-stage flow still contains only two stages.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
builder: includes the Node runtime, build tools, and test tools needed for compilation.runner: receives only the static files required for deployment.
COPY --from=builder ... is the key move. It lets you choose exactly which files cross the stage boundary, so source code, package managers, and temporary build files do not leak into the runtime image.
This two-stage version is enough for very small projects. In practice, Node apps often add the deps stage shown above because dependency installation is expensive and benefits from stable caching.
Write the Node app Dockerfile
Follow this mini mission:
- Prepare a sample app.
npx degit sveltejs/template docker-multi-stage-app
cd docker-multi-stage-app
npm install
npm run build
Confirm the local build first.
- Create a
.dockerignorefile first.
node_modules
.git
build
Dockerfile.single
This keeps unrelated files out of the build context, which improves cache reuse and avoids copying local junk into intermediate layers.
- Create the Dockerfile.
# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Why the extra deps stage? Each Dockerfile instruction creates a cached layer. The deps stage copies only package.json and package-lock.json, so Docker usually reuses it when you edit app code but keep the lockfile the same. That means the builder stage can start with cached dependencies instead of rerunning npm ci on every rebuild.
Why not use npm ci --omit=dev here? Many frontend builds need dev dependencies such as bundlers, TypeScript, or framework build tooling. Installing the full dependency tree in deps is the safer default for a beginner tutorial. The final runner image still stays slim because it only receives the built files, not node_modules.
To make the cross-stage copy less mysterious, unpack this line:
COPY --from=deps /app/node_modules ./node_modules
- In the
depsstage,WORKDIR /appmeansnpm cicreates/app/node_modules. - In the
builderstage,WORKDIR /appmeans./node_modulespoints to/app/node_modules. - So this command copies the installed packages out of the
depsstage and places them where thebuilderstage expects them.
- Build and run.
docker build -t multi-stage-lab .
docker run -d -p 4173:80 --name multi-stage multi-stage-lab
curl http://localhost:4173
The container still listens on port 80 because nginx uses port 80 inside the image. The 4173:80 mapping means "open port 4173 on my machine and forward it to port 80 in the container."
Afterward run docker image ls multi-stage-lab to inspect the size. To make the comparison concrete, use this single-stage reference Dockerfile.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm install -g serve
CMD ["serve", "-s", "build", "-l", "4173"]
This kind of Dockerfile mixes build tools and runtime software in one image. Even when it works, it keeps far more packages than production needs.
Compare cache behavior and image size
1. Cache checkpoints
- The
depsstage usually reruns whenpackage.json,package-lock.json, or the base image changes. - The
builderstage reruns when your source files change, but dependency installation can still use cached layers.
docker build -t multi-stage-lab:v1 .
docker build -t multi-stage-lab:v2 .
During the second build you should see the deps stage marked as CACHED in the build output. If you edit a source file and rebuild, that is the behavior to watch for. If you change package-lock.json, expect the dependency stage to rebuild too.
2. Image size comparison
docker build -t single-stage-lab -f Dockerfile.single .
docker images | grep lab
Create a one-stage Dockerfile.single, then note how many megabytes you save with the multi-stage version. For many frontend projects, the multi-stage image ends up far smaller because the final image drops Node itself, node_modules, and build tools. A realistic result might look like single-stage-lab at 300-400 MB and multi-stage-lab at 40-70 MB, but your numbers depend on the framework, dependencies, and base images.
Common beginner mistakes
- Installing only production dependencies before
npm run build: many frontend builds need dev dependencies, sonpm ci --omit=devoften breaks the build stage. - Copying the wrong output folder: some tools produce
dist/, while the Svelte template used here producesbuild/. Always confirm your build output path before writing theCOPY --from=builder ...line. - Expecting nginx to know SPA routing automatically: copying files into
/usr/share/nginx/htmlis enough for a basic static site, but client-side routing usually needs a customnginx.conf. Part 5 covers that configuration. - Copying secrets into the build context: avoid putting
.envfiles, credentials, or private keys into the image build unless you truly intend to. Intermediate build stages can remain cached locally.
Real-world checkpoints and extension ideas
- Environment variables: Distinguish between build-time and runtime variables. For example, a static site build may need one set of values during
npm run build, while nginx only needs port and server settings at runtime. - Optional test stage: Add a
teststage beforerunnerso the image build stops when tests fail.
FROM node:20-alpine AS test
WORKDIR /app
COPY --from=builder /app ./
RUN npm test
With this pattern, Docker builds deps -> builder -> test -> runner in order. If npm test fails, the build stops at the test stage and the final runtime image is never produced.
- Cache mounts: If you enable Docker BuildKit, you can use
RUN --mount=type=cacheto keep package caches between builds. For example, run builds withDOCKER_BUILDKIT=1 docker build ...before trying this feature. - SPA routing with nginx: If your app uses client-side routing, nginx needs a fallback such as
try_files $uri $uri/ /index.html;. Without it, refreshing a deep link can return404. Part 5 covers that nginx setup in detail. - Shrink the runtime surface: Keep only the files you truly need in the runner image, or even swap to a distroless base when the runtime does not need a shell or package manager.
- Treat build stages as temporary, not secret: multi-stage builds reduce what lands in the final image, but they do not magically protect secrets copied during build. Keep credentials out of the Docker build context whenever possible.
How to apply the pattern elsewhere
Multi-stage builds are not tied to one framework. The same approach powers static-site builds, React or Vue apps, API servers, Go binaries, and Java bundles. The core mental model is "split build tools from runtime tools." Once that idea is clear, you can ask better follow-up questions: which stage should run tests, which files should cross the stage boundary, and which runtime image is truly necessary? The next post uses this foundation to show how nginx serves the resulting static assets, how to wire health checks, and how to expose ports cleanly.
💬 댓글
이 글에 대한 의견을 남겨주세요