Docker Intermediate #1: Multi-stage Builds and Image Slimming
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.
| Kind | Build time | Runtime |
|---|---|---|
Compiler (gcc, tsc, go) | needed | not needed |
Package manager (pip, npm) | needed | usually not |
Build artifact (dist/, build/) | being made | needed |
Runtime libraries (libssl, libpq) | needed | needed |
| App code | needed (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.
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 stagebuilder- 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.
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:
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: ~15MB900MB → 15MB. 60×. Unpacking it:
CGO_ENABLED=0— disable the C library dependency, producing a pure static binarygcr.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.
# 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— runtsc, producedist/.prod-deps— install production-only dependencies. Keeps dev deps out of the final image.- Final — copy
dist/plus prodnode_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.
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:
FROM ubuntu:24.04
COPY --from=ghcr.io/curtis/myapp:1.0 /myapp /usr/local/bin/myappA 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:
docker build --target builder -t myapp:builder .A common pattern is to put a separate test stage and have CI build only that one:
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.
| Image | What’s in | What’s not |
|---|---|---|
static | glibc, ca-certificates, /etc/passwd | shell, package manager, coreutils |
base | static + dynamic linker | above + |
cc | base + libgcc | above + |
nodejs20-debian12 | Node runtime | build tools |
python3-debian12 | Python runtime | pip, 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.
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: ~7MBscratch 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:
- Base image — start with
slimvariants. (alpine has musl gotchas.) - Multi-stage — leave build tools out of the final stage.
- Copy only build artifacts — Go’s single binary, Node’s
dist/, Python’swheels. - Drop dev dependencies —
npm ci --omit=dev,pip install --no-deps, etc. apt-get clean/rm -rf /var/lib/apt/lists/*— clean up inside the same RUN..dockerignore— keep the build context itself small (Basics #6).- 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:
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 namedefines a stage;COPY --from=namebrings 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.
--targetbuilds 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.