Skip to content

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.

A Node.js app built naively:

node:20 image = 1.1 GB
+ your app + node_modules = 1.2–1.5 GB

With multi-stage builds:

Build stage: compile TypeScript, install all deps
Production stage: node:20-alpine + only runtime files = ~200 MB
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # install all deps (including devDeps)
COPY . .
RUN npm run build # tsc → dist/
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev # only production deps
COPY --from=builder /app/dist ./dist # copy compiled output only
USER node
EXPOSE 3000
CMD ["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.

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore
# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS production
WORKDIR /app
COPY --from=builder /app/publish .
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

The SDK image (~800 MB) is only used to build. The runtime image (~200 MB) is what gets deployed.

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # produces dist/
# Stage 2: Serve with nginx
FROM nginx:alpine AS production
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Final image: just nginx + static files. No Node.js at all.

Go compiles to a single binary — the final image can be scratch or distroless:

FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
# Minimal final image
FROM gcr.io/distroless/static:nonroot AS production
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Result: ~5–20 MB image with no shell, no package manager, no vulnerabilities from unused packages.

Terminal window
# Build only up to the builder stage (useful for CI)
docker build --target builder -t my-app:builder .
# Build the final production stage
docker build --target production -t my-app:latest .

Order Dockerfile instructions from least to most frequently changing:

# Dependencies rarely change — put first to cache them
COPY package*.json ./
RUN npm ci
# Source code changes often — put last
COPY . .
RUN npm run build

If source code changes but package.json doesn’t, Docker uses the cached npm ci layer.

ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS builder
Terminal window
docker build --build-arg NODE_VERSION=22 -t my-app .
  • Use alpine or distroless base images for the final stage
  • Never run containers as root — add USER node or equivalent
  • Only copy what the final stage needs (--from=builder /app/dist)
  • Use .dockerignore to prevent node_modules, .git, and secrets from being sent to the build context