Docker Part 2 of 2 · Container Security
Building Minimal Container Images with Multi-Stage Builds
Build the same Go service as a single-stage image and as a multi-stage image on a distroless base, then compare size, package count, scanner findings and the user it runs as.
On this page
Overview
The runtime image is the part of a container that attackers, scanners and pull times all see. A single-stage Dockerfile ships the compiler, the package manager, a shell and every library the base image happened to include. None of it is needed to run the program, and all of it counts: each package is a future CVE, each binary is a tool for whoever gets a foothold, and each megabyte is pulled on every node the workload lands on.
A multi-stage build separates building from running. The first stage has the toolchain; the last stage has the
program and nothing else. This article builds one small Go service both ways and measures the difference with
docker image ls, Trivy and a few runtime checks. The numbers below are from those runs (Docker 29.1.5 and Trivy
0.68.2 with a database from 2026-09-13, on an arm64 host; amd64 sizes differ by a few percent).
| Image | Size | OS packages | Fixable HIGH/CRITICAL | Runs as |
|---|---|---|---|---|
| Single-stage | 994 MB | 220 | 41 | root |
| Multi-stage | 7.67 MB | 6 | 0 | nonroot |
Architecture
- Build stage. Starts from
golang:1.27-alpine, copies the module files and source, and compiles. Everything installed here (compiler, standard library sources, module cache) lives in this stage’s layers only. - Static binary.
CGO_ENABLED=0makes the Go linker produce a binary with no dependency on the C library, so it runs on a base image that has nolibcat all.-trimpathand-ldflags='-s -w'remove build paths and debug symbols. - Runtime stage. Starts from
gcr.io/distroless/static-debian13:nonroot, a 2.2 MB base with CA certificates, time zone data and anonrootuser, but no shell, no package manager and nolibc.COPY --from=buildbrings in the one file the service needs. - Discarded. The build stage is not part of the final image.
docker historyof the runtime image shows only the runtime stage’s instructions. - Trivy scan. Runs against the runtime image, the artifact that will be deployed, with the same gate as in Container Image Scanning with Trivy.
- Registry. Only an image that passed the scan is pushed.
Prerequisites
- Docker with BuildKit (the default builder since Docker Engine 23.0)
- Trivy 0.6x (
trivy --version) for the comparison - Network access to Docker Hub and
gcr.io
Implementation
The service
A health endpoint is enough to show the mechanics. It has no third-party dependencies, so go.mod has no
require block and no go.sum; a real project would copy both.
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
addr := ":8080"
if p := os.Getenv("PORT"); p != "" {
addr = ":" + p
}
log.Printf("listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
module example.com/healthd
go 1.27
The single-stage build
This is the Dockerfile most tutorials start with. It works, and it ships the whole toolchain.
FROM golang:1.27
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN go build -o /healthd .
CMD ["/healthd"]
docker build --pull -f Dockerfile.single -t lab/healthd:single .
docker image ls lab/healthd:single --format '{{.Tag}} {{.Size}}'
docker run --rm lab/healthd:single id
single 994MB
uid=0(root) gid=0(root) groups=0(root)
994 MB for a program whose own binary is 8 MB, running as root because the golang image does not set a user.
Trivy explains where the rest went:
trivy image --severity HIGH,CRITICAL --ignore-unfixed --quiet lab/healthd:single
┌──────────────────────────────────────────────┬──────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ lab/healthd:single (debian 13.6) │ debian │ 41 │ - │
├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ healthd │ gobinary │ 0 │ - │
├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ usr/local/go/bin/go │ gobinary │ 0 │ - │
├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ usr/local/go/bin/gofmt │ gobinary │ 0 │ - │
├──────────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ usr/local/go/pkg/tool/linux_arm64/compile │ gobinary │ 0 │ - │
│ … │ │ │ │
└──────────────────────────────────────────────┴──────────┴─────────────────┴─────────┘
lab/healthd:single (debian 13.6)
Total: 41 (HIGH: 29, CRITICAL: 12)
41 fixable HIGH and CRITICAL findings across 15 Debian packages, and 1,935 findings at all severities when
unfixed ones are included. The application binary contributes none of them. Grouping the fixable findings by
package shows what the golang image drags in: the four perl packages account for 28 of the 41, including all
twelve CRITICAL findings (CVE-2026-13221, CVE-2026-42496, CVE-2026-8376); the rest are in pcre2,
sqlite3, gzip, openssl, libssh2 and python3.13. A Go HTTP server needs none of these at runtime. The
count also moves with the age of the tag: the same Dockerfile built from the older golang:1.25 tag produced 107
fixable findings in the same scan.
The multi-stage build
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/healthd .
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/healthd /healthd
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/healthd"]
Four decisions carry the result:
CGO_ENABLED=0produces a statically linked binary. Without it,netandos/usermay link against the C library, and the binary fails on a base that has nolibc(see Troubleshooting).-trimpath -ldflags='-s -w'strips file system paths, the symbol table and DWARF debug information. Go keeps the line tables it needs for panic stack traces and the build information Trivy reads, so scanning and crash output still work; interactive debugging with Delve does not.gcr.io/distroless/static-debian13:nonrootis the smallest Google distroless variant: a root filesystem with/etc/passwd,/etc/group,/tmp, CA certificates andtzdata. There is no shell, noapt, nolibc.USER nonroot:nonrootis already the default in the:nonroottag; restating it in the Dockerfile keeps the intent visible and survives a change of base image.
docker build --pull -f Dockerfile -t lab/healthd:multi .
docker image ls lab/healthd --format '{{.Tag}} {{.Size}}'
multi 7.67MB
single 994MB
The runtime image is the 2.2 MB base plus a 5.4 MB binary (8.0 MB before -s -w). docker history shows the
layers that survived:
docker history lab/healthd:multi --format '{{.Size}}\t{{.CreatedBy}}'
0B ENTRYPOINT ["/healthd"]
0B EXPOSE [8080/tcp]
0B USER nonroot:nonroot
5.44MB COPY /out/healthd /healthd # buildkit
261kB bazel build //common:cacerts_debian13_arm64…
…
824kB bazel build @trixie//tzdata-legacy/arm64:data…
759kB bazel build @trixie//tzdata/arm64:data…
273kB bazel build @trixie//base-files/arm64:data…
Nothing from the build stage is present: no RUN go build, no /src, no module cache.
Configuration
Choosing the runtime base
The runtime base decides what your binary can rely on. Pick the smallest one that satisfies the binary, not the smallest one that exists.
| Base image | Adds | Use for |
|---|---|---|
scratch | Nothing | Static binaries that make no TLS calls and need no time zones |
gcr.io/distroless/static-debian13 | CA certificates, tzdata, /etc/passwd, /tmp | Static Go and Rust binaries: the default choice |
gcr.io/distroless/base-nossl-debian13 | The above plus glibc | Dynamically linked binaries that do not use OpenSSL |
gcr.io/distroless/base-debian13 | The above plus libssl | Dynamically linked binaries, Go with CGO_ENABLED=1 |
gcr.io/distroless/cc-debian13 | The above plus libgcc1 | Rust and D programs linked against glibc |
alpine | BusyBox shell, apk, musl | When a shell is a hard requirement and musl is acceptable |
Every distroless image has :nonroot tags that set USER nonroot (UID 65532) and :debug tags that add a
BusyBox shell for local troubleshooting. The debug tags are for a laptop, not a cluster.
Interpreted languages follow the same pattern with a different payload: build a virtual environment or
node_modules in the first stage, copy it into a slim runtime, and keep the lockfile so the scanner can still see
the dependencies. The lab Secure Docker Images with Trivy works through
that for Python.
Layer order and caching
Copy the dependency manifests before the source so a code change does not invalidate the download layer. For a project with dependencies the build stage becomes:
FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/healthd .
The cache mounts persist the module and build caches between builds without writing them into any layer. Add a
.dockerignore with at least .git, dist and local secrets so COPY . . does not pull them into the build
stage.
Building for the cluster’s architecture
An image built on an Apple Silicon laptop is linux/arm64. If the cluster runs amd64 nodes, build with
--platform linux/amd64, or build a multi-architecture image in CI:
docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/healthd:1.0.0 --push .
Go cross-compiles without extra tooling, so the build stage itself does not change.
Verification
Each check below is a property the multi-stage image should have. Run them against the image you will deploy.
# 1. Runs as the nonroot user (UID 65532), verified from the host
docker run --rm -d --name hd -p 127.0.0.1:18081:8080 lab/healthd:multi
docker top hd -o user,pid,args
# 2. Serves traffic
curl -s http://127.0.0.1:18081/healthz
docker rm -f hd
# 3. Has no shell to exec into
docker run --rm --entrypoint sh lab/healthd:multi; echo "exit=$?"
# 4. Passes the scan gate at every severity
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed --quiet lab/healthd:multi; echo "exit=$?"
USER PID COMMAND
65532 181656 /healthd
ok
docker: Error response from daemon: … exec: "sh": executable file not found in $PATH
exit=127
┌─────────────────────────────────┬──────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├─────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ lab/healthd:multi (debian 13.6) │ debian │ 0 │ - │
├─────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ healthd │ gobinary │ 0 │ - │
└─────────────────────────────────┴──────────┴─────────────────┴─────────┘
exit=0
Trivy lists eight packages in the image: six Debian packages (base-files, ca-certificates, media-types,
netbase, tzdata, tzdata-legacy) and, from the binary’s embedded build information, the module itself and
stdlib v1.27.1. A scan without the severity filter also returns zero findings on the day of writing. That will
not stay true forever; see the next section.
Security Considerations
- No shell changes how you debug.
docker exec -it … shandkubectl execstop working. Usekubectl debug -it <pod> --image=busybox:1.37 --target=<container>to attach an ephemeral container that shares the process namespace, or run the:debugdistroless tag locally. Do not add a shell to the production image to make debugging convenient. - Non-root is one layer. Pair it with
readOnlyRootFilesystem: true,allowPrivilegeEscalation: falseand dropped capabilities in the Pod’ssecurityContext, and let therestrictedPod Security Standard enforce it (see the Kubernetes Security Checklist). - Build-stage secrets do not leak into the runtime image, because its layers are never part of it. They do
still land in the build cache and in
docker historyof the intermediate stage. UseRUN --mount=type=secretrather thanARGfor tokens needed during the build. - Stripping symbols is not obfuscation. The build information, package paths and strings remain readable. Do
not rely on
-s -wto hide anything. - Sign and attest what you ship. A minimal image is easier to sign and to describe in an SBOM. The lab Generate an SBOM with Syft and scan it with Grype covers the inventory side.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
exec /healthd: no such file or directory on a working binary | Dynamically linked binary on a base without libc | Build with CGO_ENABLED=0, or switch to base-debian13 |
x509: certificate signed by unknown authority | scratch has no CA bundle | Use static-debian13, or copy /etc/ssl/certs/ca-certificates.crt from the build stage |
unknown time zone Europe/Berlin | scratch has no tzdata | Use static-debian13, or import _ "time/tzdata" in the program |
permission denied when writing a file | Process runs as UID 65532; the target directory is root-owned | Write under /tmp or a mounted volume; set fsGroup in Kubernetes |
kubectl exec fails with executable file not found | No shell in the image | kubectl debug with a --target container, or the :debug tag locally |
| Scanner reports no Go packages | Binary built outside a module or with Go older than 1.18, so no build information is embedded | Build inside a module with a current Go; go version -m ./healthd should list the dependencies |
Production Recommendations
- Reference base images by digest (
gcr.io/distroless/static-debian13:nonroot@sha256:…) and let a dependency bot open the update merge requests. A tag that moves under you is an unreviewed change. - Rebuild and re-scan on a schedule, not only on code changes. A weekly pipeline that rebuilds every service from the current base is cheap insurance.
- Keep the scan gate on the runtime image in CI, as in the GitLab CI DevSecOps pipeline, and fail on fixable HIGH and CRITICAL findings.
- Use
ENTRYPOINTin exec form so signals reach the process directly; there is no shell to forward them. - Health checks belong in the Kubernetes probe, not in a Dockerfile
HEALTHCHECKthat would need a client binary the image does not have.
Conclusion
The same 20 lines of Go produced a 994 MB image with 41 fixable findings and root by default, or a 7.7 MB image with none and a non-root user. The difference is entirely in the Dockerfile: compile in one stage, copy one file into a base that contains only what the binary needs. Everything that follows in this series, scanning, signing, admission control, gets easier when the image has this little in it.
References
Keep reading