Back to blog
5 min read

Docker Multi-Stage Builds: Shrinking Your Image 40x

Is your Go app's Docker image 900 MB? A hands-on guide to cutting it down to ~20 MB with multi-stage builds, while improving security and speeding up deploys.

GoPythonDocker
Docker Multi-Stage Builds: Shrinking Your Image 40x

The problem

Many Dockerfiles look like this:

FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o server .
CMD ["./server"]

It works. But the final image still contains the entire Go toolchain, build cache, source code and unnecessary utilities. It can easily exceed 900 MB. That means:

  • Slow deploys: hundreds of megabytes are pushed to the registry and pulled to the server every time.
  • Larger attack surface: every extra package in the image is a potential vulnerability.
  • Wasted resources: disk, bandwidth and CI time.

The fix: multi-stage builds

The idea is simple: build the app in one stage, and only run the finished binary in another.

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

# Stage 2: runtime
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Result: an image of roughly 15–25 MB.

What changed?

  1. AS builder: we name the first stage so we can copy files out of it later.
  2. go.mod is copied separately: dependencies change less often than code, so Docker caches this layer and builds get faster.
  3. CGO_ENABLED=0: produces a static binary with no dependency on external C libraries.
  4. -ldflags="-s -w": strips debug information, making the binary smaller.
  5. distroless: no shell, no package manager. Just your app.
  6. USER nonroot: the container runs without root privileges.

Note: distroless has no shell, so docker exec -it ... sh won't work. For debugging, use the static-debian12:debug variant.

Does it work for Python too?

Yes. Build wheels in the builder stage and use a slim image at runtime:

FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
CMD ["python", "main.py"]

The gains aren't as dramatic as with Go. Still, compilers and build tools stay out of the final image, which helps both size and security.

Takeaway

Multi-stage builds are one of the easiest optimizations out there: five minutes spent on your Dockerfile pays off in speed and security on every deploy. If your project still has a single FROM, try it today and compare the output of docker images.