Docker Multi-Stage Builds
Docker Multi-Stage Builds
Section titled “Docker Multi-Stage Builds”Multi-stage builds use multiple FROM instructions in a single Dockerfile. Each stage can copy artifacts from previous stages — so build tools, dev dependencies, and intermediate files don’t end up in the final image.
Why Multi-Stage Builds
Section titled “Why Multi-Stage Builds”A Node.js app built naively:
node:20 image = 1.1 GB+ your app + node_modules = 1.2–1.5 GBWith multi-stage builds:
Build stage: compile TypeScript, install all depsProduction stage: node:20-alpine + only runtime files = ~200 MBNode.js / TypeScript Example
Section titled “Node.js / TypeScript Example”# Stage 1: BuildFROM node:20-alpine AS builder
WORKDIR /appCOPY package*.json ./RUN npm ci # install all deps (including devDeps)
COPY . .RUN npm run build # tsc → dist/
# Stage 2: ProductionFROM node:20-alpine AS production
WORKDIR /appCOPY package*.json ./RUN npm ci --omit=dev # only production deps
COPY --from=builder /app/dist ./dist # copy compiled output only
USER nodeEXPOSE 3000CMD ["node", "dist/server.js"]The final image contains:
- Node.js runtime
node_modules(prod deps only)- Compiled
dist/files
TypeScript compiler and dev dependencies are left behind in the builder stage.
.NET Example
Section titled “.NET Example”# Stage 1: BuildFROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder
WORKDIR /srcCOPY *.csproj ./RUN dotnet restore
COPY . .RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 2: RuntimeFROM mcr.microsoft.com/dotnet/aspnet:8.0 AS production
WORKDIR /appCOPY --from=builder /app/publish .
EXPOSE 8080ENTRYPOINT ["dotnet", "MyApp.dll"]The SDK image (~800 MB) is only used to build. The runtime image (~200 MB) is what gets deployed.
React / Vite Frontend
Section titled “React / Vite Frontend”# Stage 1: BuildFROM node:20-alpine AS builder
WORKDIR /appCOPY package*.json ./RUN npm ci
COPY . .RUN npm run build # produces dist/
# Stage 2: Serve with nginxFROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80CMD ["nginx", "-g", "daemon off;"]Final image: just nginx + static files. No Node.js at all.
Go Example
Section titled “Go Example”Go compiles to a single binary — the final image can be scratch or distroless:
FROM golang:1.22-alpine AS builder
WORKDIR /srcCOPY go.mod go.sum ./RUN go mod download
COPY . .RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Minimal final imageFROM gcr.io/distroless/static:nonroot AS production
COPY --from=builder /app/server /server
EXPOSE 8080ENTRYPOINT ["/server"]Result: ~5–20 MB image with no shell, no package manager, no vulnerabilities from unused packages.
Targeting Specific Stages
Section titled “Targeting Specific Stages”# Build only up to the builder stage (useful for CI)docker build --target builder -t my-app:builder .
# Build the final production stagedocker build --target production -t my-app:latest .Caching Layers
Section titled “Caching Layers”Order Dockerfile instructions from least to most frequently changing:
# Dependencies rarely change — put first to cache themCOPY package*.json ./RUN npm ci
# Source code changes often — put lastCOPY . .RUN npm run buildIf source code changes but package.json doesn’t, Docker uses the cached npm ci layer.
Build Arguments
Section titled “Build Arguments”ARG NODE_VERSION=20FROM node:${NODE_VERSION}-alpine AS builderdocker build --build-arg NODE_VERSION=22 -t my-app .Best Practices
Section titled “Best Practices”- Use alpine or distroless base images for the final stage
- Never run containers as root — add
USER nodeor equivalent - Only copy what the final stage needs (
--from=builder /app/dist) - Use
.dockerignoreto preventnode_modules,.git, and secrets from being sent to the build context