Sachin Chaurasiya

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.

Author
Sachin Chaurasiya
Sachin Chaurasiya
Published
Reading time
12 min read
Difficulty
beginner

Reviewed Tested with Docker 29.1.5, Trivy 0.68.2, Go 1.27.1, distroless static-debian13

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).

ImageSizeOS packagesFixable HIGH/CRITICALRuns as
Single-stage994 MB22041root
Multi-stage7.67 MB60nonroot

Architecture

Diagram · Multi-stage build to a distroless runtime image
Multi-stage build to a distroless runtime imageThe build stage starts from a Go toolchain image and compiles the source into one static binary. Only that binary is copied into the runtime stage, which starts from a distroless static base with no shell or package manager and runs as a non-root user. The compiler, module cache and build tools stay in the discarded build stage. Trivy scans the runtime image before it is pushed to the registry.COPYbuildcopynot shippedscanpushSourcego.mod · main.goBuild stagegolang:1.27-alpine1Static binaryCGO_ENABLED=02Runtime stagedistroless static3Discardedtoolchain · cache4Trivy scanHIGH · CRITICAL5Registrytagged image6

The build stage starts from a Go toolchain image and compiles the source into one static binary. Only that binary is copied into the runtime stage, which starts from a distroless static base with no shell or package manager and runs as a non-root user. The compiler, module cache and build tools stay in the discarded build stage. Trivy scans the runtime image before it is pushed to the registry.

  1. 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.
  2. Static binary. CGO_ENABLED=0 makes the Go linker produce a binary with no dependency on the C library, so it runs on a base image that has no libc at all. -trimpath and -ldflags='-s -w' remove build paths and debug symbols.
  3. Runtime stage. Starts from gcr.io/distroless/static-debian13:nonroot, a 2.2 MB base with CA certificates, time zone data and a nonroot user, but no shell, no package manager and no libc. COPY --from=build brings in the one file the service needs.
  4. Discarded. The build stage is not part of the final image. docker history of the runtime image shows only the runtime stage’s instructions.
  5. Trivy scan. Runs against the runtime image, the artifact that will be deployed, with the same gate as in Container Image Scanning with Trivy.
  6. 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=0 produces a statically linked binary. Without it, net and os/user may link against the C library, and the binary fails on a base that has no libc (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:nonroot is the smallest Google distroless variant: a root filesystem with /etc/passwd, /etc/group, /tmp, CA certificates and tzdata. There is no shell, no apt, no libc.
  • USER nonroot:nonroot is already the default in the :nonroot tag; 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 imageAddsUse for
scratchNothingStatic binaries that make no TLS calls and need no time zones
gcr.io/distroless/static-debian13CA certificates, tzdata, /etc/passwd, /tmpStatic Go and Rust binaries: the default choice
gcr.io/distroless/base-nossl-debian13The above plus glibcDynamically linked binaries that do not use OpenSSL
gcr.io/distroless/base-debian13The above plus libsslDynamically linked binaries, Go with CGO_ENABLED=1
gcr.io/distroless/cc-debian13The above plus libgcc1Rust and D programs linked against glibc
alpineBusyBox shell, apk, muslWhen 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 … sh and kubectl exec stop working. Use kubectl debug -it <pod> --image=busybox:1.37 --target=<container> to attach an ephemeral container that shares the process namespace, or run the :debug distroless 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: false and dropped capabilities in the Pod’s securityContext, and let the restricted Pod 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 history of the intermediate stage. Use RUN --mount=type=secret rather than ARG for tokens needed during the build.
  • Stripping symbols is not obfuscation. The build information, package paths and strings remain readable. Do not rely on -s -w to 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

SymptomCauseFix
exec /healthd: no such file or directory on a working binaryDynamically linked binary on a base without libcBuild with CGO_ENABLED=0, or switch to base-debian13
x509: certificate signed by unknown authorityscratch has no CA bundleUse static-debian13, or copy /etc/ssl/certs/ca-certificates.crt from the build stage
unknown time zone Europe/Berlinscratch has no tzdataUse static-debian13, or import _ "time/tzdata" in the program
permission denied when writing a fileProcess runs as UID 65532; the target directory is root-ownedWrite under /tmp or a mounted volume; set fsGroup in Kubernetes
kubectl exec fails with executable file not foundNo shell in the imagekubectl debug with a --target container, or the :debug tag locally
Scanner reports no Go packagesBinary built outside a module or with Go older than 1.18, so no build information is embeddedBuild 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 ENTRYPOINT in 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 HEALTHCHECK that 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