[Docker Series Part 4] Split Build and Runtime Environments with Multi-stage Builds

한국어 버전

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

  1. Why multi-stage builds matter
  2. Basic structure: builder vs. runner
  3. Writing a multi-stage Dockerfile for a Node app
  4. Compare cache behavior and image size
  5. Real-world checkpoints and extension ideas
  6. How to apply the pattern elsewhere

Terms introduced here

  1. Stage: A build block defined by one FROM instruction inside a Dockerfile.
  2. 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 --from patterns.

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.

For Node-based frontend builds, the most useful beginner pattern has three stages.

  • deps: install dependencies from the lockfile
  • builder: copy source files and produce the final static assets
  • runner: 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:

  1. 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.

  1. Create a .dockerignore file 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.

  1. 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 deps stage, WORKDIR /app means npm ci creates /app/node_modules.
  • In the builder stage, WORKDIR /app means ./node_modules points to /app/node_modules.
  • So this command copies the installed packages out of the deps stage and places them where the builder stage expects them.
  1. 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 deps stage usually reruns when package.json, package-lock.json, or the base image changes.
  • The builder stage 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, so npm ci --omit=dev often breaks the build stage.
  • Copying the wrong output folder: some tools produce dist/, while the Svelte template used here produces build/. Always confirm your build output path before writing the COPY --from=builder ... line.
  • Expecting nginx to know SPA routing automatically: copying files into /usr/share/nginx/html is enough for a basic static site, but client-side routing usually needs a custom nginx.conf. Part 5 covers that configuration.
  • Copying secrets into the build context: avoid putting .env files, 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

  1. 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.
  2. Optional test stage: Add a test stage before runner so 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.

  1. Cache mounts: If you enable Docker BuildKit, you can use RUN --mount=type=cache to keep package caches between builds. For example, run builds with DOCKER_BUILDKIT=1 docker build ... before trying this feature.
  2. 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 return 404. Part 5 covers that nginx setup in detail.
  3. 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.
  4. 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.

💬 댓글

이 글에 대한 의견을 남겨주세요