Multi-stage container builds: control the runtime artifact
Build with compilers in one stage and ship only the runtime artifact. Work through a runnable Docker example, dependency checks and final-image verification.
TL;DR: Use one stage to produce a tested artifact and another to define exactly what runs. Inspect the final image for runtime dependencies and permissions; a small image can still contain the wrong binary, leaked credentials or an unusable entrypoint.
A stage is a filesystem boundary
Each FROM starts a build stage. A later stage can copy selected files from an earlier one without inheriting its complete filesystem. Docker's multi-stage build guide documents named stages and COPY --from.
This is useful when compilation needs a compiler and headers that the running service does not need. It also makes the artifact boundary reviewable. Copying the entire build directory into the final image can undo much of the benefit by bringing source trees, package caches or temporary files along with the executable.
The diagram describes inclusion, not a security sandbox. Code executed during a build can still read available build inputs or credentials. Restrict those independently.
Build a small executable with an explicit runtime
Create main.c in an empty exercise directory:
#include <stdio.h>
int main(void) {
puts("invoice-worker build check");
return 0;
}
Use this Dockerfile in the same directory. These version tags make the teaching example readable; a production pipeline should resolve and review approved base-image digests.
FROM alpine:3.22 AS build
RUN apk add --no-cache build-base
WORKDIR /src
COPY main.c .
RUN mkdir -p /out && cc -O2 -Wall -Wextra -o /out/worker main.c
FROM alpine:3.22 AS runtime
COPY --from=build /out/worker /usr/local/bin/worker
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/worker"]
Build and run the final stage:
docker build -t dip-multistage-example .
docker run --rm dip-multistage-example
docker run --rm --entrypoint sh dip-multistage-example \
-c 'test "$(id -u)" = 10001 && ! command -v cc'
The first run prints the message and exits successfully. The second verifies that the configured user is non-root and the compiler command is absent. Those checks establish two specific properties; they do not certify the whole image as secure.
The executable uses Alpine's musl runtime. Copying it into an unrelated runtime without its required loader and libraries may fail even when the binary file exists. Keep the build and runtime ABI compatible, or deliberately produce a static binary and verify its requirements.
Choose runtime contents from application behavior
| Application behavior | Runtime requirement to verify | Failure if omitted |
|---|---|---|
| Dynamically linked executable | Matching loader and shared libraries | Process cannot start |
| HTTPS requests to public services | Suitable certificate trust store | Certificate validation fails |
| Writing temporary output | Writable, correctly owned directory | Permission error under non-root user |
| Loading templates at runtime | Required data files in expected path | Build succeeds; requests fail |
| Child-process execution | Explicitly supported helper program | Missing executable during a rarely used path |
A scratch image can be appropriate for a self-contained binary with deliberately supplied data. It is a poor default if nobody has enumerated the program's runtime requirements. The example above keeps a small Linux runtime to make dependency inspection straightforward.
Test the final image. Tests run only in the build stage may accidentally rely on a package that never reaches production. Exercise at least startup and a representative operation using the same user and filesystem permissions as deployment.
Keep build secrets and cache decisions separate
Do not pass a private registry credential through a Dockerfile ARG and assume that discarding the build stage makes it safe. Use supported secret mounts and ensure the build command does not copy or print the secret. Docker's Dockerfile reference describes instruction behavior and mount options.
Review the build context as well. A broad COPY . . can include files that were never intended as inputs. Use an appropriate ignore file and copy dependency manifests before frequently changing source when the dependency installation can be cached independently. Image layers and build cache explains why instruction order affects reuse.
Promote the tested image digest through environments. Rebuilding the same source later can select different package contents or base layers when inputs are not pinned. The artifact you tested must be the one you deploy.
Check a tempting size optimization
Self-check: removing the compiler cuts the image substantially, but the final container fails with “not found” when starting an executable that is visibly present. Should you copy the compiler back?
First inspect the executable format, target architecture and dynamic loader requirements. A missing loader can make execution fail despite the file's presence. Restore the required runtime dependency or rebuild for the intended runtime. Adding the entire toolchain can mask the packaging mistake and leaves the artifact contract unexplained.