Containers & Orchestration·7 min read·

Curing Image Bloat With Scratch and Distroless Base Images

Shrink container images with multi-stage builds, the empty scratch base, and distroless runtimes — smaller pulls, fewer CVEs, faster starts.

NB

Netbay Infrastructure Team

Netbay Engineering

On this page

A container image that ships a full OS plus build tools you will never run is the most common source of cloud waste: slow pulls, a larger registry bill, and a bigger attack surface for the same one-line binary. The cure is picking a base you actually need at runtime. For compiled languages that means the empty scratch image or a distroless runtime, and for interpreted languages it means a minimal runtime without a shell and package manager.

This post shows how to reduce a typical image to a tenth of its size while keeping it fully runnable.

The Multi-Stage Baseline

Before trimming the base, build in stages so the final image never contains compilers. The classic pattern compiles or assembles in one stage and copies only the artifacts and their minimal runtime into a second stage.

dockerfile
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The Alpine result is already a fraction of the build stage, but you can go further.

The Scratch Base: Nothing but Your Binary

scratch is an empty layer — literally no filesystem. A statically linked binary runs fine on top of it, which is why CGO_ENABLED=0 matters: it stops Go from linking against glibc. The result is often under 20MB with zero package manager and zero shell.

dockerfile
FROM scratch
COPY --from=build /app/server /server
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
USER 65534:65534
ENTRYPOINT ["/server"]

Run it with docker run, and docker image ls shows a tiny footprint. The tradeoff is real: no shell means debugging inside the container is harder, so keep a debug build or docker cp in your toolkit.

Distroless: A Minimal Runtime Layer

Distroless images from Google contain just enough to run a language runtime — glibc, certs, and timezone data — with no shell, no package manager, and running as a non-root user by default. They are the middle ground for languages that need a runtime but not an OS.

dockerfile
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs20-debian12
COPY --from=build /app/build /app
EXPOSE 3000
CMD ["/app/index.js"]

Pull size drops and so does the expected CVE count, because there is no userland to patch beyond the runtime itself.

Measuring the Win

See the difference with docker history and layer inspection, then enforce a size budget in CI:

bash
docker build -t myapp:trim .
docker image ls myapp:trim
docker history myapp:trim --no-trunc | head
docker scout cves myapp:trim   # lower exposed packages
full OS image ~ 500-900 MB alpine runtime ~ 20-50 MB scratch ~ 5-15 MB smaller pull, fewer packages, faster start

Takeaway

Multi-stage builds, the empty scratch base, and distroless runtimes slice image size and attack surface at once, and are the standard for production Go, Rust, Node, and Python services. Slim images deploy faster and are cheaper to pull repeatedly. Put your trimmed image on a Netbay VPS — provision one at netbayhosts.in and time how quickly a sub-20MB image goes from pull to running.

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