Docker & ContainersV Β· Dockerfiles & BuildKitSecure construction
Secure image construction
What you'll learn
- Construct images with security built in from the start
- Eliminate secrets, package managers, and shells from production images
- Attach SBOM and provenance attestations, and know which are on by default
- Sign the digest that will actually be deployed, not the one that happened to be printed
- Design a vulnerability gate that survives an unfixable CVE
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12
A βsecureβ image is one that an attacker cannot easily use as a beachhead. The fundamentals: no shell, no package manager, no secrets, non-root by default, declared healthchecks, declared metadata.
Almost all of that is subtraction, and subtraction is cheap. The parts that add something β attestations and signatures β are where the effort goes, and where the failures are subtle enough to pass review.
A secure-image checklist
- Non-root user. Bake
USERinto the Dockerfile. - No shell in production. Use distroless or scratch unless you need a shell.
- No package manager. Apt/apk/npm should not be in the runtime image.
- No secrets in any layer. Use BuildKit secrets at build time, external stores at runtime.
- Pinned base. Pin by digest, not tag.
- Pinned package versions.
apt-get install -y --no-install-recommends nginx=1.24.0-2ubuntu7.4. - Healthcheck defined, in exec form, calling only binaries the image contains.
- Read-only filesystem where possible.
docker run --read-onlyplus a tmpfs for scratch space. - No new privileges.
docker run --security-opt="no-new-privileges=true". - Dropped capabilities.
docker run --cap-drop=ALLand add back only what is needed. - SBOM attached.
docker buildx build --sbom=trueβ it is not on by default. - Provenance recorded. Attached at
mode=minby default;--provenance=mode=maxfor SLSA work. - Image signed by digest, and verified against the same digest that gets deployed.
The first four are the ones that pay. An image with no shell, no package manager and no root user removes most of the post-exploit toolkit: an attacker who achieves code execution inside your container cannot install anything, cannot spawn a shell for an interactive session, and cannot write to the filesystem if it is mounted read-only.
Non-root that actually applies
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build --chown=nonroot:nonroot /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
Two details that separate this from the version that looks right and is not:
- Numeric UID.
USER nonrootneeds an/etc/passwdentry to resolve. Distroless has one;scratchdoes not. Numeric IDs always work, and β more usefully β a numericUSERis what a KubernetesrunAsNonRootadmission check can actually evaluate, because it cannot resolve a name it has never seen. USERis a default, not a control.docker run --user 0overrides it, as doesuser: rootin a Compose file. The Dockerfile expresses intent; the runtime decides. If it matters, enforce it at the runtime layer as well.
IMAGE=myorg/myapp:1.0.0
U=$(docker image inspect --format '{{.Config.User}}' "$IMAGE")
case "$U" in
''|0|root|0:*|root:*) echo "FAIL: $IMAGE runs as root (User='$U')"; exit 1 ;;
*) echo "OK: $IMAGE runs as $U" ;;
esacAn empty Config.User is the common case and the dangerous one: it
means no USER was ever set, and the container runs as UID 0.
Attestations: what is on by default
This is the single most commonly misstated thing about modern builds, so it is worth being exact. From Dockerβs attestation documentation:
- Provenance β βProvenance attestations with the
mode=minlevel are added to images by default.β - SBOM β not generated unless you ask.
--sbom=true.
So a build you did not configure already carries a minimal
provenance attestation and no SBOM at all. Both can be turned off
wholesale with the BUILDX_NO_DEFAULT_ATTESTATIONS environment
variable.
--provenance: mode=max
--sbom: true
--attest: type=provenance,mode=max
--push: (flag)
--metadata-file: build.json01
--provenance= mode=maxRecord full build provenance: inputs, source, materials, build steps.
Production: mode=min is the default. mode=max is what SLSA build level 3 evidence needs β and it also records build arguments, so never combine it with secrets in ARG.
02
--sbom= trueGenerate a software bill of materials and attach it to the image.
Production: Off by default. Without it, "we have SBOMs" is an aspiration.
β Assuming --sbom is implied by --provenance. They are independent flags.
03
--attest= type=provenance,mode=maxThe general form; --provenance and --sbom are shorthands for it.
Production: Use the shorthands unless you need a type they do not cover.
04
--push= (flag)Push the result, including attestations, to the registry.
Production: Attestations only exist in a registry. A --load build discards them unless the daemon uses the containerd image store.
β Building with --sbom=true and --load, then wondering where the SBOM went.
05
--metadata-file= build.jsonWrite the resulting digests to a file.
Production: This is where you get containerimage.digest for signing and deployment. Do not scrape it from stdout.
There is no --scan flag on docker buildx build. Scanning is a
separate step, run against the built image by a scanner you
control.
Generating an SBOM at build time
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build --chown=nonroot:nonroot /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]
docker buildx build --sbom=true --provenance=mode=max \
--metadata-file build.json \
--tag registry.example.com/myorg/myapp:1.0.0 --push .
The SBOM is attached to the image in the registry. Consumers query it by reference or by digest:
docker buildx imagetools inspect registry.example.com/myorg/myapp:1.0.0 --format '{{json .SBOM}}'
docker buildx imagetools inspect registry.example.com/myorg/myapp:1.0.0 --format '{{json .Provenance}}'
Signing with Cosign
Sign the digest, never the tag. A tag is a mutable pointer; signing it signs whatever it happened to point at when you ran the command, and verifies nothing about what is running later.
# The digest of the image you just built, taken from the metadata file
DIGEST=$(jq -r '."containerimage.digest"' build.json)
IMAGE="registry.example.com/myorg/myapp@$DIGEST"
# Key-based signing
cosign generate-key-pair
cosign sign --key cosign.key "$IMAGE"
cosign verify --key cosign.pub "$IMAGE"
For CI, keyless signing via an OIDC identity avoids managing a
private key at all. In Cosign v2 this is the default behaviour β
there is no COSIGN_EXPERIMENTAL any more β and verification must
name the identity you expect:
cosign sign --yes "$IMAGE"
cosign verify \
--certificate-identity-regexp 'https://github.com/myorg/myapp/.*' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"$IMAGE"
Cosign supports key rotation, transparency logs (Rekor), and policy-driven admission control (Kyverno, OPA).
Scanning for vulnerabilities
Run a scanner you control, against the built image, as an explicit pipeline step:
trivy image registry.example.com/myorg/myapp:1.0.0
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
registry.example.com/myorg/myapp:1.0.0
Failing the build on HIGH/CRITICAL is a policy decision. Document it β and read the next callout before you enable it, because the naive version of this gate does not survive contact with a real base image.
Verifying the construction, not just the intent
Four assertions that can each fail, on the built artefact:
IMAGE=registry.example.com/myorg/myapp:1.0.0
# 1. Not running as root
docker image inspect --format '{{.Config.User}}' "$IMAGE" | grep -qE '^[1-9][0-9]*' \
|| { echo 'FAIL: no numeric non-root USER'; exit 1; }
# 2. No shell and no package manager in any layer
docker save "$IMAGE" | tar -xO --wildcards '*/layer.tar' | tar -t 2>/dev/null \
| grep -E '(^|/)(sh|bash|dash|ash|apt|apt-get|apk|dnf|yum)$' \
&& { echo 'FAIL: shell or package manager present'; exit 1; }
# 3. No credential-shaped strings in the build history
docker history --no-trunc --format '{{.CreatedBy}}' "$IMAGE" \
| grep -Ei 'token|secret|password|api[-_]?key' \
&& { echo 'FAIL: credential in image history'; exit 1; }
# 4. An SBOM is actually attached
docker buildx imagetools inspect "$IMAGE" --format '{{json .SBOM}}' \
| grep -q 'SPDX' || { echo 'FAIL: no SBOM attached'; exit 1; }
echo 'image gate passed'Every one of those has a failing mode you can demonstrate, which is what makes it verification rather than ceremony. Run the gate against a deliberately bad image once, confirm it fails, and only then trust it when it passes.
Knowledge check
Knowledge check Β· 6 questions
Q1. Secure image construction includes:
Q2. `docker commit` is a secure way to build images.
Q3. Which statement about buildx attestation defaults is correct?
Q4. A pipeline runs `cosign sign` against an image tag and verification passes, but the admission controller reports `no matching signatures` at deploy time. What is the most likely cause?
Q5. A CRITICAL CVE with no available fix appears in your base image and the CI gate blocks all deployments. Which are sound responses? Select all that apply.
Q6. `docker buildx build --scan` runs a vulnerability scan as part of the build.
Passing score: 75%. Answers are checked in this browser.