Docker Multi-Stage Builds
Patterns for multiple languages.
Why Multi-Stage Builds Matter
A typical application Docker image built naively can be 1-2 GB — containing compilers, build tools, and source code that aren't needed at runtime. Multi-stage builds let you use one stage for building and another for running, producing images that are 10-50x smaller, faster to pull, and have a dramatically reduced attack surface.
The Problem: Bloated Images
# Bad: Single-stage build (800MB+)
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]This image includes npm, the full Node.js development environment, all dev dependencies, source TypeScript files, and the node_modules folder — none of which are needed to run the compiled application.
Multi-Stage Solution
# Stage 1: Build
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && cp -R node_modules prod_modules
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]Result: Image drops from 800MB to ~120MB.
Patterns by Language
.NET
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
USER 1000
ENTRYPOINT ["dotnet", "MyApp.dll"]Go
FROM golang:1.21-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server
FROM scratch
COPY --from=build /app/server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/server"]Go with scratch base image: final image is just 5-15MB!
Python
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
USER 1000
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000"]Security Best Practices
- Use distroless or alpine base images — Fewer packages means fewer CVEs
- Run as non-root — Always add
USER 1000orUSER node - Don't copy secrets — Use BuildKit secrets mounts for build-time credentials
- .dockerignore — Exclude .git, node_modules, .env, and test files from build context
- Pin base image digests — Use
node:18-alpine@sha256:abc...for reproducible builds
Build Cache Optimization
Order COPY commands from least to most frequently changed:
- Dependency manifests (package.json, go.mod, *.csproj)
- Dependency installation (npm ci, go mod download, dotnet restore)
- Source code (COPY . .)
- Build command
This ensures Docker cache is used for dependency installation (the slowest step) unless dependencies actually change.
Image Size Comparison
| Language | Single-Stage | Multi-Stage | Reduction |
|---|---|---|---|
| Node.js | 800MB | 120MB | 85% |
| .NET | 1.2GB | 210MB | 83% |
| Go | 400MB | 10MB | 97% |
| Python | 900MB | 180MB | 80% |
Eazy SaaS Tip: We include multi-stage Dockerfiles in every project we deliver. Combined with image scanning in CI/CD pipelines, this approach gives our clients secure, minimal images that deploy 5x faster and reduce ECR storage costs by 80%.