Skip to main content
RunBook Academy

Docker & ContainersV Β· Dockerfiles & BuildKitSecure construction

Secure image construction

Advanced⏱ ~30 mindocker

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

Not yet marked complete on this device.

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 USER into 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-only plus a tmpfs for scratch space.
  • No new privileges. docker run --security-opt="no-new-privileges=true".
  • Dropped capabilities. docker run --cap-drop=ALL and add back only what is needed.
  • SBOM attached. docker buildx build --sbom=true β€” it is not on by default.
  • Provenance recorded. Attached at mode=min by default; --provenance=mode=max for 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 nonroot needs an /etc/passwd entry to resolve. Distroless has one; scratch does not. Numeric IDs always work, and β€” more usefully β€” a numeric USER is what a Kubernetes runAsNonRoot admission check can actually evaluate, because it cannot resolve a name it has never seen.
  • USER is a default, not a control. docker run --user 0 overrides it, as does user: root in a Compose file. The Dockerfile expresses intent; the runtime decides. If it matters, enforce it at the runtime layer as well.
Read-only / Safeassert non-root
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" ;;
esac

An 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=min level 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.

docker buildx buildThe attestation and output flags that matter for a release build
--provenance: mode=max
--sbom: true
--attest: type=provenance,mode=max
--push: (flag)
--metadata-file: build.json
  1. 01--provenance= mode=max

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

  2. 02--sbom= true

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

  3. 03--attest= type=provenance,mode=max

    The general form; --provenance and --sbom are shorthands for it.

    Production: Use the shorthands unless you need a type they do not cover.

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

  5. 05--metadata-file= build.json

    Write 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:

Read-only / Safeimage gate
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

  1. Q1. Secure image construction includes:

  2. Q2. `docker commit` is a secure way to build images.

  3. Q3. Which statement about buildx attestation defaults is correct?

  4. 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?

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

  6. Q6. `docker buildx build --scan` runs a vulnerability scan as part of the build.

Passing score: 75%. Answers are checked in this browser.