๐งฑ 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)