Docker Intermediate #1: Multi-stage Builds and Image Slimming

8 min read

The Docker Basics series pinned down defining and running a single container. The Intermediate series adds another layer on top — the operational tools. The first post covers the area where you get the biggest payoff fast: multi-stage builds.

This post in the Docker Intermediate series:

  • #1 Multi-stage builds and image slimming ← this post
  • #2 Build cache — layer ordering
  • #3 docker-compose basics — web + db
  • #4 compose deep dive — depends_on, healthcheck, profiles
  • #5 Environment variables and secrets
  • #6 Logging and debugging

Build-time vs. runtime requirements #

The one-line Dockerfile examples of Basics start small and balloon as you move into a real project. The cause is almost always the same:

  • Compilers / build tools baked into the image
  • Dev dependencies (test runners, linters) installed alongside production ones
  • node_modules / .venv / build artifacts present twice
  • apt cache left behind

Compilers especially are needed only at build time but tend to stick around in the image. Go’s go build, Node’s tsc, C’s gcc — once you have the build artifact, they’re done, yet they often stay frozen into the image.

KindBuild timeRuntime
Compiler (gcc, tsc, go)needednot needed
Package manager (pip, npm)neededusually not
Build artifact (dist/, build/)being madeneeded
Runtime libraries (libssl, libpq)neededneeded
App codeneeded (not after build for languages like Go)depends

The tool that handles this split naturally inside one Dockerfile is the multi-stage build.

Basic syntax — FROM ... AS name #

One Dockerfile can contain several FROM lines. Each FROM starts a new stage.

Simplest multi-stage
FROM python:3.14 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --target=/build/deps -r requirements.txt

FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /build/deps /app/deps
COPY app.py .
ENV PYTHONPATH=/app/deps
CMD ["python", "app.py"]
  • FROM python:3.14 AS builder — name the first stage builder
  • The second FROM python:3.14-slim — start a fresh, clean stage
  • COPY --from=builder /build/deps /app/deps — pull just the artifact from the first stage

The final image contains only the stage after the last FROM. The compilers, build tools, and caches inside builder are discarded. Only what you explicitly bring across with --from=builder survives.

Go — the most dramatic effect #

Go produces a static binary, so multi-stage builds shine the brightest here.

Go — single-stage (bloated)
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]
# Final image: ~900MB (entire Go toolchain)

The same app multi-stage:

Go — multi-stage
FROM golang:1.23 AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM gcr.io/distroless/static-debian12
COPY --from=builder /build/myapp /myapp
CMD ["/myapp"]
# Final image: ~15MB

900MB → 15MB. 60×. Unpacking it:

  • CGO_ENABLED=0 — disable the C library dependency, producing a pure static binary
  • gcr.io/distroless/static-debian12 — minimal image without a shell or package manager (covered later)
  • The final stage holds just the compiled binary and the OS files it depends on

In production you’ll see Go containers built almost exactly like this.

Node.js — only tsc’s output #

A TypeScript project builds with tsc into dist/. Production only needs that artifact.

Node TypeScript — multi-stage
# 1) deps stage — for cache reuse
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 2) build stage — run tsc
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# 3) production deps separately
FROM node:20-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# 4) Final image
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/server.js"]

Four stages — each with a clear role:

  • deps — install all dependencies (including dev). Used for build and cache.
  • builder — run tsc, produce dist/.
  • prod-deps — install production-only dependencies. Keeps dev deps out of the final image.
  • Final — copy dist/ plus prod node_modules. No build tools.

USER node drops to an unprivileged user in one line. The official Node image ships that user pre-created.

Python — separate build deps via wheel #

Python isn’t as dramatic as Go, but separating C-extension build tools (gcc, headers) helps a lot.

Python — multi-stage
FROM python:3.14 AS builder
WORKDIR /build
RUN pip wheel --no-cache-dir --wheel-dir /wheels \
    psycopg2 cryptography
# These packages have C extensions and need gcc, libpq-dev, libssl-dev etc. to build

FROM python:3.14-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
    psycopg2 cryptography \
    && rm -rf /wheels

COPY app.py .
CMD ["python", "app.py"]

builder runs the compile in the full image and produces wheels (.whl). The final image is slim-based and only installs from the wheels — no compiler in the image.

You’d reach for this only when image slimming truly matters in production. Most of the time, python:3.14-slim + requirements.txt is small enough.

Other uses of --from #

--from can pull from external images too:

Direct from another image
FROM ubuntu:24.04
COPY --from=ghcr.io/curtis/myapp:1.0 /myapp /usr/local/bin/myapp

A way to grab specific files from another image. Sometimes used when static assets are built into a separate image and combined into a runtime image.

--target — build only up to a stage #

For dev / CI flows where you want to build only some stages, use --target:

Build only up to the builder stage
docker build --target builder -t myapp:builder .

A common pattern is to put a separate test stage and have CI build only that one:

Separate test stage
FROM node:20-slim AS deps
# ...

FROM node:20-slim AS builder
COPY --from=deps /app/node_modules ./node_modules
# ...

FROM builder AS test
RUN npm run test
RUN npm run lint

FROM node:20-slim AS runner
COPY --from=builder /app/dist ./dist
# ...

CI runs docker build --target test . for tests; the deploy build runs --target runner (or default) all the way through. Both share the dependency cache.

Distroless — Google’s minimal images #

There’s an image family at gcr.io/distroless/... from Google. They contain only what’s strictly needed to run an app.

ImageWhat’s inWhat’s not
staticglibc, ca-certificates, /etc/passwdshell, package manager, coreutils
basestatic + dynamic linkerabove +
ccbase + libgccabove +
nodejs20-debian12Node runtimebuild tools
python3-debian12Python runtimepip, venv tooling

Pros: small, smaller attack surface (no shell or coreutils to exploit). Cons: docker exec -it ... sh doesn’t work. There’s no shell. Debugging gets harder.

Reach for distroless when production container security matters, or when there’s no reason to have a shell inside the container. The general starting point is slim rather than distroless.

Scratch — a truly empty image #

scratch is a virtual image Docker provides that is completely empty — zero files. You drop a static binary into it.

Go + scratch
FROM golang:1.23 AS builder
WORKDIR /build
COPY . .
RUN CGO_ENABLED=0 go build -o myapp

FROM scratch
COPY --from=builder /build/myapp /myapp
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/myapp"]
# Final image: ~7MB

scratch doesn’t even have ca-certificates, timezone data, or /etc/passwd — you bring exactly what you need. Distroless’s static is essentially this preassembled. Unless your app is a pure static Go binary of the simplest kind, distroless is safer.

Slimming checklist #

The image-shrinking levers, all together:

  1. Base image — start with slim variants. (alpine has musl gotchas.)
  2. Multi-stage — leave build tools out of the final stage.
  3. Copy only build artifacts — Go’s single binary, Node’s dist/, Python’s wheels.
  4. Drop dev dependenciesnpm ci --omit=dev, pip install --no-deps, etc.
  5. apt-get clean / rm -rf /var/lib/apt/lists/* — clean up inside the same RUN.
  6. .dockerignore — keep the build context itself small (Basics #6).
  7. distroless / scratch — for when slimness really matters.

A small Go API around ~15MB, a Node API around ~120MB, and a Python API around ~150MB are all well-trimmed.

Real example — Next.js standalone #

The multi-stage pattern around Next.js’s standalone output shows up often, so worth listing:

Next.js standalone
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

Setting output: 'standalone' in next.config.js produces .next/standalone/ containing only what’s needed to run. Copying that across yields a small image.

Wrap-up #

The picture from this post:

  • Multi-stage builds split build dependencies from runtime dependencies within a single Dockerfile.
  • FROM ... AS name defines a stage; COPY --from=name brings only the artifact across.
  • Go with a static binary + scratch / distroless drops images from GBs to tens of MBs.
  • Node TypeScript’s standard pattern is the deps / builder / prod-deps / runner four-stage layout.
  • Python uses wheels to keep C-extension build tools out of the final image.
  • --target builds only some stages — useful for splitting CI test from deploy.
  • distroless / scratch is for when slimness is critical. The trade-off is harder debugging.

In the next post (#2 Build cache — layer ordering), we add another layer on top of multi-stage — BuildKit cache mounts, COPY --link, and external caches (GHA / registry) to make builds faster still.

X