๐งฑ What is a Multi-Stage Build in Docker?
Multi-stage build allows you to use multiple
FROM
statements in a single Dockerfile to:
- Build the app in one stage ๐๏ธ
- Copy only what's needed to a smaller final image ๐ฆ
โ Why Do We Need It?
โ Main Goals:
๐ Benefit | ๐ฌ Why it Matters |
---|---|
โก Smaller Images | Only copy what's needed into final image |
๐ More Secure | No dev tools or secrets in production image |
๐ ๏ธ Cleaner CI/CD | Separate build & runtime environment |
๐ Better Layer Caching | Speeds up builds |
๐ Environment Separation | One image builds everything! |
๐ง Real-World Analogy
Imagine:
- ๐๏ธ Stage 1 = Construction site (messy, heavy tools)
- ๐ Stage 2 = Finished house (clean, cozy)
You build in the messy environment, but only move the furniture into the clean house. ๐งน
๐งช Multi-Stage Build Syntax
# ๐จ Stage 1: Build Stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ๐ฆ Stage 2: Final Production Image
FROM node:20-alpine
WORKDIR /app
# Copy only final build artifacts (no source or node_modules)
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm ci --omit=dev
# Set env vars, port and run
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/index.js"]
๐ Key Concepts Explained
Keyword | Meaning |
---|---|
AS builder |
Give a name to this stage |
--from=builder |
Copy files from previous stage |
npm ci --omit=dev |
Install only production deps |
COPY . . |
Used only in build stage to avoid code bloat in final image |
๐ฆ Before vs After: Image Size
Approach | Image Size | Contents |
---|---|---|
๐ตโ๐ซ Traditional Single Build | ~900MB | Full source code + dev dependencies |
๐คฉ Multi-Stage Build | ~200MB | Just built app + runtime dependencies |
๐ฅ Real Project Example: React App
# Step 1: Build React App
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Step 2: Serve using NGINX
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
โ This builds the app with Node.js, and serves the static files via NGINX (no Node.js in final image!)
๐ฏ Common Multi-Stage Use Cases
Use Case | Description |
---|---|
๐ป Frontend builds | Use node + nginx combo |
๐ง Backend builds | Build with TS/Go/Rust, then copy binaries only |
๐งช Testing stage | Add test/linting in one stage, skip in final |
๐ฆ CI/CD pipelines | Clean, reproducible builds across stages |
๐งฐ Pro Tips & Best Practices
๐ก Tip | โ Recommendation |
---|---|
Use --omit=dev
|
Strip dev-only packages in final stage |
Use .dockerignore
|
Exclude node_modules , .git , tests/ , etc |
Use labels | Add metadata like version, author, etc |
Donโt copy everything | Use exact COPY paths for size control |
Use named stages | Easier to copy from (--from=builder ) |
Keep final image minimal | Just enough to run your app (no tools!) |
๐ Combine with Docker Compose
You can define multi-stage builds in your Dockerfile and just run:
docker-compose build
docker-compose up
Your services will use the optimized final image automatically ๐ง โ
๐ ๏ธ Example Multi-Stage for TypeScript API
# Stage 1: Compile TS
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
# Stage 2: Run with only JS output
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
CMD ["node", "dist/server.js"]
โ Summary: When to Use Multi-Stage Builds?
โ Always use if:
- You're using build tools like
tsc
,webpack
,vite
- You want minimal production images
- You want to separate testing/staging/building
- You want faster CI builds & smaller attack surface
๐งพ Final TL;DR Cheatsheet
Stage | Purpose | Base Image | Output |
---|---|---|---|
Stage 1 (builder) | Build, compile, test |
node , golang , rust , etc. |
/dist , /build , etc. |
Stage 2 (prod) | Serve/run app only |
node:alpine , nginx , etc. |
Final slim image |
๐ฆ Full Dockerfile (Context Recap)
FROM node:20-alpine3.19 as base
# Stage 1: Build Stuff
FROM base as builder
WORKDIR /home/build
COPY package*.json .
COPY tsconfig.json .
RUN npm install
COPY src/ src/
RUN npm run build
# Stage 2: Runner
FROM base as runner
WORKDIR /home/app
COPY --from=builder /home/build/dist dist/
COPY --from=builder /home/build/package*.json .
RUN npm install --omit=dev
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
USER nodejs
EXPOSE 8000
ENV PORT=8000
CMD [ "npm", "start" ]
๐ฏ A2Z Breakdown of Each Section
๐งฑ FROM node:20-alpine3.19 as base
๐ง What it does:
- Starts from a minimal Node.js 20 Alpine image
- Alpine is lightweight (~5MB), good for small, fast images
-
as base
names this stage for reuse
๐งฉ Think of
base
like a shared template that both stages use.
๐จ Stage 1: Builder
FROM base as builder
WORKDIR /home/build
๐ง What happens here:
- We switch to a new build stage, using
base
image -
WORKDIR /home/build
sets a directory for our build process
COPY package*.json .
COPY tsconfig.json .
RUN npm install
๐ฆ Install dependencies:
-
package*.json
copied to install dependencies -
tsconfig.json
is required for TypeScript compilation -
npm install
installs all dependencies (dev + prod)
COPY src/ src/
RUN npm run build
๐ ๏ธ Build your app:
- Copies your app's TypeScript code
-
npm run build
compiles TS into JS โ typically inside/dist
โ End Result of Stage 1:
A folder
/home/build/dist
with compiled production-ready JS output.
๐ Stage 2: Runner
FROM base as runner
WORKDIR /home/app
๐ What it does:
- We now create a fresh container just for running the app.
-
WORKDIR /home/app
is where your app will run from.
COPY --from=builder /home/build/dist dist/
COPY --from=builder /home/build/package*.json .
๐ Copy built artifacts only:
- Only copy the
dist/
folder and package files (no source, no tsconfig) - Ensures the final image is slim & clean
RUN npm install --omit=dev
๐ Production-only install:
- Installs only prod dependencies (no dev tools like
eslint
,tsc
, etc.) - Keeps final image light and secure โ
๐ฎ Add Secure Non-Root User
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodejs
USER nodejs
๐ Why?
- Running as
root
is dangerous in containers โ - We create a user
nodejs
with limited permissions for safety - UID/GID
1001
is just an arbitrary non-root system user
๐ Port & Env Setup
EXPOSE 8000
ENV PORT=8000
-
EXPOSE 8000
: Documents that the app uses port 8000 -
ENV PORT=8000
: Sets the default port for app to use internally
You still need to use
-p
to map it to host:
docker run -p 8000:8000 <image>
๐ฆ Start the App
CMD [ "npm", "start" ]
๐ข Default entrypoint when container runs
- This triggers your
"start"
script frompackage.json
:
"start": "node dist/index.js"
โ Summary Table
๐น Section | ๐ Purpose |
---|---|
FROM base |
Reuse image to reduce duplication |
builder |
Compiles TypeScript into JS |
runner |
Runs a minimal production image |
npm install in builder |
Installs full deps for building |
npm install --omit=dev in runner |
Installs only what's needed to run |
COPY --from=builder |
Efficient file copy without rebuild |
USER nodejs |
Enhances container security |
๐ Resulting Benefits
๐ Benefit | โ Achieved |
---|---|
Small Image | โ Only runtime code in final image |
Secure | โ Non-root user, no dev tools |
Faster Builds | โ Reuses build layers |
Clean Code Separation | โ No TypeScript or build files inside final container |
Portable | โ Can run on any platform with Node 20 |
๐ง Bonus Tip: View Image Sizes
docker images
Compare the multi-stage image (~100MB) vs a single-stage image (~400โ600MB) ๐คฏ
๐ Final Thoughts
This approach follows Docker best practices:
- Multi-stage
- Production-ready
- Secure by default
- Reproducible builds
Top comments (0)