A naive Dockerfile ships the compiler, the package manager and the source code into production, which makes images large and gives an attacker more tools. Multi-stage builds compile in one stage and copy only the result into a minimal final stage. BuildKit, Docker’s newer build engine, makes the process faster and adds secret handling and cache mounts.
Versions in use on this date. Docker Engine 19.03.13 (September 2020). BuildKit is included in 19.03 and enabled with DOCKER_BUILDKIT=1; it is not yet the default. The advanced RUN --mount features use the docker/dockerfile:experimental frontend. Examples use Go 1.15 (August 2020) and Alpine 3.12.
Stages and what ships
flowchart TB
SRC[Source code and go.mod] --> B
subgraph B[Stage 1 build image golang 1.15 about 300 MB]
DEP[go mod download<br/>cached layer] --> COM[go build static binary]
end
COM -->|COPY --from=build binary only| R
subgraph R[Stage 2 runtime image alpine 3.12 about 6 MB]
BIN[App server binary] --> USR[Non-root user]
end
R --> IMG[Final image around 12 MB]
B -.->|discarded| DROP[Compiler and sources not shipped]
A single-stage image (the problem)
FROM golang:1.15
WORKDIR /src
COPY . .
RUN go build -o /server ./cmd/server
CMD ["/server"]
This produces an image of several hundred MB with the entire Go toolchain and your source inside.
The multi-stage version
# syntax=docker/dockerfile:experimental
FROM golang:1.15-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 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server
FROM alpine:3.12
RUN apk add --no-cache ca-certificates tzdata \
&& addgroup -S app && adduser -S -G app app
COPY --from=build /out/server /usr/local/bin/server
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]
export DOCKER_BUILDKIT=1
docker build -t myorg/server:1.0.0 .
docker images myorg/server # compare the sizes
docker build --target build -t server-build . # build only the first stage, for debugging
Key points:
CGO_ENABLED=0builds a static binary that does not need glibc, so it runs on Alpine or evenscratch.COPY --from=buildtakes just the binary. The compiler, module cache and source stay behind in an earlier stage.- Copy
go.modandgo.sumfirst so the dependency download is cached until they change. - A non-root
USERand installed CA certificates (for outbound HTTPS) are the minimum for a runtime image.
For languages like Node.js or Java, the same pattern applies: build in a fat image (node:12, maven), run in a slim one (node:12-alpine, a JRE image).
What BuildKit adds
| Feature | Use |
|---|---|
| Parallel stages | Independent stages build at the same time; unused stages are skipped |
RUN --mount=type=cache |
Persistent package or compiler caches between builds (needs the experimental syntax line) |
RUN --mount=type=secret |
Use a secret at build time without it landing in a layer |
--ssh |
Forward your SSH agent to clone private repos during the build |
| Better output | --progress=plain shows full logs |
Using a secret safely:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
Unlike ARG or ENV, the secret does not appear in docker history or in the final image.
Choosing the runtime base
| Base | Size | Notes |
|---|---|---|
scratch |
0 | Just your static binary; no shell, no CA certificates unless you copy them |
alpine |
~6 MB | Shell and package manager; musl libc |
gcr.io/distroless/static |
~2 MB | No shell, includes CA certs; harder to debug |
debian:buster-slim |
~70 MB | glibc compatibility |
Smaller images pull faster, start faster and contain fewer vulnerabilities to scan. Debug by running a separate debug image or the build stage, not by shipping a shell in production.
Habits
- Use
.dockerignoreto keep.git, tests and local build artefacts out of the context. - Pin base tags (
golang:1.15.2-alpine) and rebuild regularly for patches. - Run the container as non-root, with a read-only root filesystem where possible (
docker run --read-only). - Scan the final image (see the Trivy post) and keep the build stage out of registries.
- In CI, use
--cache-fromor a registry cache to reuse layers between runs.