devops

Docker Multi-Stage Builds: Slash Your Image Size by 90%

Master Docker multi-stage builds to shrink image sizes from 1GB to under 100MB. Real examples for Go, Node.js, and Python with production best practices.

June 24, 2026·9 min read·
#docker#container#devops#ci-cd#best-practices

Introduction

A typical Node.js Docker image weighs 1.2 GB. A Go binary image can be 800 MB. Most of that weight comes from build tooling, dev dependencies, and operating system packages that have no business being in a production container.

Docker multi-stage builds solve this by separating the build environment from the runtime environment. You compile your application in a fat container with all the tools, then copy only the final artifact into a minimal runtime image.

The result: images that are 10x to 50x smaller, faster to deploy, and more secure.

How Multi-Stage Builds Work

A multi-stage Dockerfile has multiple FROM statements. Each FROM begins a new stage. You can copy artifacts from earlier stages into later ones, leaving behind everything you do not need.

Basic Multi-Stage Syntax

# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server

# Stage 2: Runtime
FROM alpine:3.19
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The first stage (builder) pulls a 350 MB Go image, downloads modules, and compiles. The second stage starts from a 5 MB Alpine image and copies only the compiled binary. The final image is under 15 MB.

Before and After

Without multi-stage, a typical Go Dockerfile:

FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o server .
EXPOSE 8080
ENTRYPOINT ["./server"]

Image size: approximately 800 MB. And that binary was compiled with debug symbols, CGO enabled, and no stripping.

With multi-stage:

FROM golang:1.22-alpine AS builder
WORKDIR /app
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=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Image size: approximately 12 MB. The scratch image contains absolutely nothing but the binary.

Real-World Examples by Language

Go: From 800 MB to 12 MB

# Build stage
FROM golang:1.22-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w -extldflags=-static" \
    -o /app/server ./cmd/server

# Runtime stage
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /app/server /usr/local/bin/server
EXPOSE 8080
USER 1000:1000
ENTRYPOINT ["server"]

Key optimizations:

  • CGO_ENABLED=0 produces a static binary with no external dependencies
  • -ldflags="-s -w" strips debug symbols, reducing the binary by 30 percent
  • alpine:3.19 is only 5 MB
  • Running as non-root user (USER 1000) improves security

Node.js: From 1.2 GB to 180 MB

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# Copy only production dependencies and built output
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]

Key optimizations:

  • npm ci --only=production installs only production dependencies, skipping devDependencies like TypeScript, ESLint, and testing tools
  • The devDependencies can reduce image size by 70 percent
  • Only dist (compiled output) and production node_modules are copied
  • Using node:20-alpine instead of node:20 saves about 600 MB

Python: From 900 MB to 150 MB

# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Runtime stage
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 8000
USER 1000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

Key optimizations:

  • python:3.12-slim instead of python:3.12 drops the image from 900 MB to 120 MB
  • --user flag installs packages to the user site-packages directory, which we copy to the final image
  • --no-cache-dir prevents pip from caching downloaded packages in the image

Advanced Multi-Stage Patterns

Using Build Arguments for Environment-Specific Builds

ARG GO_VERSION=1.22

FROM golang:${GO_VERSION}-alpine AS builder
ARG APP_VERSION=latest
WORKDIR /app
COPY . .
RUN go build -ldflags="-X main.version=${APP_VERSION}" -o /server

FROM alpine:3.19
COPY --from=builder /server /server
CMD ["/server"]

Build with different versions:

docker build --build-arg GO_VERSION=1.21 --build-arg APP_VERSION=v2.0 -t myapp .

Caching Dependencies Separately

Layer caching makes rebuilds faster. Copy dependency files first and install before copying source code:

FROM node:20-alpine AS builder
WORKDIR /app

# Cache layer: dependencies rarely change
COPY package.json package-lock.json ./
RUN npm ci

# This layer changes with every source edit
COPY . .
RUN npm run build

FROM node:20-alpine
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

Docker caches each layer. When you change source code, only the COPY . and RUN npm run build layers re-execute. The npm ci layer is cached from the previous build, saving 30-60 seconds per rebuild.

Multi-Architecture Builds with BuildKit

Build for both amd64 and arm64 in a single command:

docker buildx build --platform linux/amd64,linux/arm64 \
  -t myapp:latest --push .

The multi-stage pattern works identically for both architectures because the compiler produces architecture-specific binaries.

Image Size Optimization Techniques

Using Distroless Images

Google's distroless images contain only your application and its runtime dependencies, no shell, no package manager, no utilities:

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /server .

FROM gcr.io/distroless/static-debian12:latest
COPY --from=builder /server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Distroless images are approximately 2 MB plus your binary. They reduce the attack surface significantly since there is no shell, no apt, and no utilities that an attacker could use.

Image Size Comparison

| Approach | Go Image | Node.js Image | Python Image | |----------|---------|---------------|--------------| | Full base image | 800 MB | 1.2 GB | 900 MB | | Alpine base | 280 MB | 350 MB | 300 MB | | Multi-stage + Alpine | 15 MB | 180 MB | 150 MB | | Multi-stage + Distroless | 12 MB | 150 MB | 120 MB | | Multi-stage + Scratch | 10 MB | Not applicable | Not applicable |

The table makes the case clearly: multi-stage builds are not optional if you care about deployment speed and security.

Production Best Practices

Use .dockerignore

Prevent build context bloat by ignoring unnecessary files:

.git
node_modules
dist
.env
*.md
Dockerfile
docker-compose.yml
.gitignore

Without .dockerignore, Docker sends your entire project directory (including node_modules, which can be hundreds of MB) to the Docker daemon on every build.

Scan for Vulnerabilities

After building your slim image, scan it:

# Using Docker Scout (built into Docker Desktop)
docker scout quickview myapp:latest

# Using Trivy (open source)
trivy image myapp:latest

# Using Snyk
snyk container test myapp:latest

Smaller images have fewer packages, which means fewer CVEs. A 12 MB Go binary has approximately zero vulnerabilities versus a 800 MB Go image that may have dozens.

Tag with Git SHA, Not Latest

docker build -t myapp:$(git rev-parse --short HEAD) .
docker push myapp:$(git rev-parse --short HEAD)

Integrate into CI/CD

# .github/workflows/docker-build.yml
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: registry.example.com/myapp:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The GitHub Actions cache action shares build cache across CI runs, making repeated builds 50-80 percent faster.

Debugging Multi-Stage Builds

When your multi-stage build fails, you can debug by targeting a specific stage:

# Build only the builder stage
docker build --target builder -t myapp-builder .

# Run the builder stage to inspect
docker run -it --entrypoint sh myapp-builder

# Check what is inside the final image
docker run --entrypoint '' myapp ls -la /app

Common Issues

| Problem | Cause | Fix | |---------|-------|-----| | Binary not found in runtime | Wrong COPY --from path | Verify binary path with --target builder | | Dynamic linking errors | CGO binary needs libc | Use CGO_ENABLED=0 or musl | | Permission denied | Binary owned by root in scratch | COPY with --chown=nonroot | | Node modules missing | Wrong production install | Use npm ci --only=production | | Timezone not set | Alpine has no timezone data | Install tzdata package in runtime stage |

Further Reading

Conclusion

Multi-stage builds are one of the highest-impact optimizations you can make to your container workflow. They reduce image sizes by 10x to 50x, speed up deployments, reduce storage costs, and improve security by minimizing the attack surface.

The pattern is always the same: build in a fat stage, copy only the binary or compiled output to a minimal runtime stage.

Start today: convert your most-used Dockerfile to multi-stage. Pick one language from the examples above (Go, Node.js, or Python) and apply the pattern. Then scan the old and new images with a vulnerability scanner to see the security improvements firsthand. The 10 minutes you invest will pay back every time you deploy.

#docker#container#devops#ci-cd#best-practices
D
DevToCashAuthor

Senior DevOps/SRE Engineer · 10+ years · Professional Trader (IDX, Crypto, US Equities)

I write about real infrastructure patterns and trading strategies I use in production and in live markets. No courses, no affiliate hype — just documentation of what actually works.

More about me →