Multi-Stage Builds, Minimal Runtime Images, Distroless Patterns, Debug Stages, and Build Separation: Guided Hands-On Workflow and Core Operations
Turn a deliberately toolchain-heavy Go image into named build, single-stage, debug, and scratch runtime targets. The workflow records base references, Buildx/BuildKit state, target-specific image size and user configuration, static-binary evidence, metadata-file digests, and a safe debugging path that never modifies the release image.
Learning objectives
- Capture Docker, Buildx, builder, frontend, source, and base-image evidence before building any target.
- Build one Dockerfile as single-stage, debug, and scratch runtime targets and explain exactly which stages BuildKit needs for each target.
- Verify the release runs as a numeric non-root user and that the scratch target has no shell/package manager by design.
- Compare image size, history/configuration, binary hashes, and metadata-file output without treating size as a security verdict.
- Use a separate debug target to inspect the same application artifact while leaving the release image immutable.
1. Lab scope: one source tree, four named targets
This lab uses a tiny Go program and one Dockerfile with
build, single, debug, and
runtime stages. The mandatory path is local and free.
It does not publish an image, change daemon configuration, require a
registry credential, or require a paid scanner. Base images are
public; record the exact digests your builder resolves before
treating the result as reproducible.
2. Preflight: capture builder and base assumptions
mkdir -p ch10-lab/evidence
cd ch10-lab
docker version | tee evidence/00-docker-version.txt
docker context show | tee evidence/01-context.txt
docker buildx version | tee evidence/02-buildx-version.txt
docker buildx inspect --bootstrap | tee evidence/03-builder.txt
docker buildx imagetools inspect golang:1.26 \
| tee evidence/04-golang-base.txt
docker buildx imagetools inspect busybox:1.37.0 \
| tee evidence/05-debug-base.txt
The tag is human-readable input; the resolved manifest/index and
platform-specific digest are immutable identity. For release
engineering, substitute reviewed
name@sha256:… references after recording the correct
platform digest. The examples keep readable tags so the lab remains
copyable across architectures, but the evidence packet must
explicitly state that limitation if you do not substitute digests.
3. Create the synthetic application
package main
import (
"fmt"
"os"
"runtime"
)
func main() {
fmt.Printf("chapter10 uid=%d gid=%d platform=%s/%s\\n",
os.Getuid(), os.Getgid(), runtime.GOOS, runtime.GOARCH)
}
sha256sum main.go | tee evidence/06-main-go-sha256.txt
The program deliberately needs no network, secrets, writable data, shell, CA store, or timezone database. That makes a scratch runtime valid for this exercise. A real application must enumerate its own runtime dependencies rather than copying this base choice blindly.
4. One Dockerfile, explicit responsibilities
# syntax=docker/dockerfile:1
ARG GO_BASE=golang:1.26
ARG DEBUG_BASE=busybox:1.37.0
FROM ${GO_BASE} AS build
WORKDIR /src
COPY main.go .
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags='-s -w -buildid=' -o /out/app ./main.go
RUN sha256sum /out/app > /out/app.sha256
# Deliberately heavy comparison target: it inherits the complete build stage.
FROM build AS single
ENTRYPOINT ["/out/app"]
# Separate diagnostic target with a shell and basic BusyBox tools.
FROM ${DEBUG_BASE} AS debug
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
# Production exercise target: no shell, package manager, or extra files.
FROM scratch AS runtime
COPY --from=build --chown=65532:65532 /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
sha256sum Dockerfile | tee evidence/07-dockerfile-sha256.txt
The single target intentionally demonstrates what
happens when the build environment itself becomes the runtime. The
debug and runtime targets copy the same
application artifact but offer different operational surfaces.
5. Build the toolchain-heavy comparison target
docker buildx build \
--progress=plain \
--target single \
--load \
--metadata-file evidence/08-single-metadata.json \
--label devops-academy.lab=ch10 \
-t devops-academy-ch10:single \
. 2>&1 | tee evidence/08-single-build.txt
docker image inspect devops-academy-ch10:single \
--format 'ID={{.Id}} Size={{.Size}} User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}}' \
| tee evidence/09-single-inspect.txt
This target is expected to be large because it inherits the Go toolchain, source tree, and build-stage filesystem. That is a controlled comparison, not the recommended release design.
6. Build the separate debug target
docker buildx build \
--progress=plain \
--target debug \
--load \
--metadata-file evidence/10-debug-metadata.json \
--label devops-academy.lab=ch10 \
-t devops-academy-ch10:debug \
. 2>&1 | tee evidence/10-debug-build.txt
docker run --rm devops-academy-ch10:debug \
| tee evidence/11-debug-run.txt
docker run --rm --entrypoint /bin/sh devops-academy-ch10:debug \
-c 'echo "debug-shell uid=$(id -u) gid=$(id -g)"; ls -ln /app; sha256sum /app' \
| tee evidence/12-debug-shell.txt
The debug target remains non-root by default. It has a shell because BusyBox supplies one, but that shell belongs to the debug image identity, not the release image.
7. Build and prove the scratch runtime target
docker buildx build \
--progress=plain \
--target runtime \
--load \
--metadata-file evidence/13-runtime-metadata.json \
--label devops-academy.lab=ch10 \
-t devops-academy-ch10:runtime \
. 2>&1 | tee evidence/13-runtime-build.txt
docker image inspect devops-academy-ch10:runtime \
--format 'ID={{.Id}} Size={{.Size}} User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}}' \
| tee evidence/14-runtime-inspect.txt
docker run --rm devops-academy-ch10:runtime \
| tee evidence/15-runtime-run.txt
The program should report UID/GID 65532. The numeric
identity works even though scratch has no /etc/passwd.
If the application requires username lookup, home-directory data,
certificates, or other OS files, those become explicit runtime
dependencies to add or a reason to select a different base.
8. Prove shell absence without “fixing” it
set +e
docker run --rm --entrypoint /bin/sh devops-academy-ch10:runtime \
> evidence/16-runtime-shell.stdout \
2> evidence/16-runtime-shell.stderr
printf 'exit=%s\n' "$?" | tee evidence/16-runtime-shell-exit.txt
set -e
The failure is expected evidence: scratch contains no
/bin/sh. Do not add a shell to the release target
merely to make this diagnostic command succeed. Use the separate
debug target when a shell is required.
9. Compare content, size direction, user, and history
for image in \
devops-academy-ch10:single \
devops-academy-ch10:debug \
devops-academy-ch10:runtime
do
docker image inspect "$image" \
--format '{{join .RepoTags ","}} size={{.Size}} user={{.Config.User}} entrypoint={{json .Config.Entrypoint}}'
done | tee evidence/17-target-comparison.txt
docker image history --no-trunc devops-academy-ch10:runtime \
| tee evidence/18-runtime-history.txt
Expect single to be much larger than
runtime; exact byte counts vary by architecture and
base updates. The comparison is operational evidence, not a
vulnerability score.
10. Extract and hash the release binary without starting it
CID="devops-academy-ch10-evidence"
docker create --name "$CID" devops-academy-ch10:runtime \
> evidence/19-container-id.txt
docker cp "$CID":/app ./evidence/runtime-app
sha256sum ./evidence/runtime-app \
| tee evidence/20-runtime-app-sha256.txt
docker rm "$CID" | tee evidence/21-evidence-container-cleanup.txt
docker cp works against a stopped container, so there
is no need to mutate or exec into the release. Compare this checksum
with the hash printed from the debug target. Both targets should
contain the same compiled artifact if the Dockerfile and inputs are
unchanged.
11. Challenge: application suddenly needs HTTPS
The current scratch image works because the sample program has no TLS client. Now imagine the program must call an HTTPS endpoint that uses a public CA. Which layer owns the failure if certificate verification fails? Name two defensible designs: explicitly copy a reviewed CA bundle into scratch, or choose a runtime base such as an appropriate Distroless image that already carries CA roots. Explain the evidence you would capture before choosing.
Knowledge check
Why does building --target runtime not require
shipping the Go compiler?
The compiler is needed to execute the build-stage graph, but
only the explicitly copied /out/app becomes part of
the scratch target filesystem.
What proves the scratch image is intentionally shell-less?
The known scratch contract plus the captured failed
/bin/sh launch. That failure is expected, not
evidence that the application itself is broken.
Why use --metadata-file in addition to
docker image inspect?
Build metadata can record result-level digest/provenance information, while image inspect shows local image/config state. They answer related but distinct identity questions.
If the runtime binary hash differs from the debug binary hash, what should you do first?
Preserve both hashes and build logs/metadata, then inspect whether the targets were built from the same build stage and inputs. Do not assume “same Dockerfile” guarantees same artifact across separate changed builds.
Why is numeric USER 65532:65532 useful in
scratch?
It establishes non-root kernel credentials without requiring a users database inside the empty filesystem, though applications that need username/home lookup may require additional identity files or another base.
Official references and version notes
-
Docker multi-stage builds
— named stages,
COPY --from, stage reuse, and stopping at a specific target. -
Dockerfile reference
—
FROM, stage naming,COPY --from,USER,ENTRYPOINT, and current frontend semantics. -
Base images
— choosing base images and creating minimal images with the
reserved
scratchbase. - Docker build best practices — small trusted bases, rebuilding, pinning, decoupling applications, and non-root considerations.
-
docker buildx build—--target,--metadata-file, progress, output, and build-result evidence. -
docker image inspect— size and runtime configuration evidence. -
docker image history— image-history evidence and its limits. - GoogleContainerTools/distroless — current Distroless image families, Debian 13 tags, nonroot/debug variants, no-shell behavior, and signature guidance.
- Distroless base image contents — static/base/base-nossl runtime contents such as CA certificates, tzdata, glibc, and libssl.
- Distroless support policy — current Debian-family support timelines.
- Docker Engine 29 release notes — current Engine baseline and bundled component updates.
- Buildx releases — current Buildx release history.
- BuildKit releases — current BuildKit and built-in Dockerfile frontend history.
Version-sensitive statements were rechecked against primary
sources on 2026-09-21. Docker Engine
29.8.1 is the current Engine 29 patch baseline
(released 2026-09-15); Buildx 0.37.1 is the
current upstream Buildx release (2026-09-11); BuildKit
0.33.0 is the current upstream BuildKit release
(2026-09-02) and ships built-in Dockerfile frontend
1.27.0. Current Distroless documentation lists
Debian 13 families with latest, nonroot,
debug, and debug-nonroot variants and
warns that images intentionally lack a shell. Always record the
actual local versions, builder/frontend, base-image digests,
target platform, and external-image identity because installation
bundles and image tags evolve independently.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.