Containers·6 min read·

Multi-Stage Builds: Slash Your Final Image Size

Use multi-stage Dockerfiles to compile in one layer and copy only the runtime artifacts forward — image sizes drop from gigabytes to tens of megabytes.

NB

Netbay Cloud Team

Netbay Engineering

On this page

Every compiler you leave in a final image is dead weight that ships to every node, fills your registry, and extends the window between "vulnerable dependency" and "running patched binary". Multi-stage builds solve this cleanly: one stage compiles and packages, a later stage copies only the artifacts forward, and the build toolchain never survives into the shipped image. Mature applications routinely drop from over a gigabyte to well under 100 MB this way — not by tuning flags, but by deleting an entire category of software from the runtime. This guide shows the pattern in a Go binary and a frontend static site, then explains the flags that keep the trick honest.

Two stages, one shipped artifact BUILDER STAGE golang image: 900 MB source + toolchain CGO_ENABLED=0 build RUNTIME STAGE scratch or alpine COPY --from=0 /out only the binary Toolchain stays in the build, never in the image

How a Multi-Stage Build Is Written

A multi-stage Dockerfile is simply several FROM lines. Everything before the second FROM is stage zero, and COPY --from=0 (or COPY --from=builder when the stage has a name) reaches back into it. Docker executes only what the final stage actually needs, so a stage whose artifacts are never copied can fail and still not break the build — which is why older single-stage files so often carried dead weight nobody noticed.

A Compiled Service on Scratch

dockerfile
# ---- stage 0: build ----
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server .

# ---- stage 1: run ----
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /out/server /server
USER 10001:10001
ENTRYPOINT ["/server"]
EXPOSE 8080

The scratch base is an intentionally empty image, so the final artifact is exactly two files: the static binary and the CA bundle it needs for TLS. CGO_ENABLED=0 produces a pure static binary with no libc dependency, and the -ldflags strip debugging symbols. A Node or Java service cannot use scratch, but the same shape works with alpine:3.21 as a minimal runtime that still carries a package manager and shell for debugging.

Frontend and Compile-and-Run Stacks

Building a Static Site and Serving It Small

dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
USER nginx

Here the runtime is nginx:alpine, the build output is a directory of static files, and the node toolchain vanishes. The two stages have entirely different personalities: stage zero is fat on purpose so the compiler has everything, stage one is starved on purpose so the deployed surface is tiny. Use --target to build just one stage during development (docker build --target build) and rely on the full target for release.

Name Stages and Copy Selectively

COPY --from can only pull paths, not run commands, so anything that needs transformation happens inside the producing stage. Give every reusable stage an AS name: it makes docker build --target builder, docker build --target test, and --target publish legible and lets one file serve CI, tests, and production.

Flags That Keep Multi-Stage Honest

Three flags matter. --target picks the stage to build. --output (docker build --output type=local,dest=./out .) dumps a stage's filesystem to the host without building a final image, which is how many CI pipelines extract just the compiled artifact. --progress=plain gives the full raw log instead of the interactive spinner, so CI failures show exactly which stage broke. Verify the payoff with docker image inspect on the finished image — compare config.Cmd, config.Healthcheck, and os/layers against the original single-stage build you replaced. The same file can also expose a developer-friendly target: keep a test stage that runs the linter and unit tests in the same Dockerfile, build it in CI with --target test, and ship the slim runtime image only on release.

Takeaway

Multi-stage builds rekey your images from "whole toolchain plus app" to "app plus what it needs at runtime", usually an order-of-magnitude size drop. That is a smaller pull on every node, a smaller scan surface in every registry, and a smaller blast radius if a base-layer library is ever compromised. The pattern is two FROM lines and one COPY --from, and the payoff compounds every time the image is deployed. Deploying on a Netbay VPS with fast, roomy SSD storage makes the pull speed difference obvious in practice — spin up a host at netbayhosts.in and rebuild your worst image today.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance