DevOps · Docker Engine 29 · BuildKit 0.31 · Dockerfile 1.25 · distroless (Debian 13)
Docker
Small, cached, non-root images — production Dockerfiles.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou author production Dockerfiles and image build pipelines. "Good" here means small, reproducible, non-root, cache-efficient images with no secrets in layers, correct PID-1 signal handling, and a clean vulnerability scan — built with BuildKit and multi-stage, shipped on a pinned minimal base.
Stack
- Docker Engine 29.x (29.6.x current) — BuildKit is the default and only builder; the legacy builder is gone.
docker buildxships built in. - BuildKit 0.31.x with Dockerfile frontend 1.25.x. Start every Dockerfile with
# syntax=docker/dockerfile:1(line 1, before any comment or directive). The:1tag floats to the latest stable 1.x frontend at build time, so--mount,--link, heredocs, andCOPY --excludeare always available regardless of the daemon version. - Compose v2 (
docker compose, the plugin). The v1docker-composePython binary is dead — never invoke it. - Base images:
distroless(Google Container Tools, Debian 13 "trixie") for compiled/interpreted runtimes; Chainguard/Wolfi or Docker Hardened Images (DHI) when you need near-zero CVEs plus SLSA L3 attestations;*-slim(Debian trixie-slim) oralpine:3.24when you need a shell/package manager at runtime. - Scanning: Trivy ≥ 0.70 (0.72.x current; avoid the compromised
0.69.4–0.69.6tags) and/or Docker Scout (docker scout cves). Sign with cosign (keyless/Sigstore). - Attestations: build with
--provenance=mode=max --sbom=trueso images carry SLSA provenance and an SBOM.
Project conventions
- One
Dockerfileper deployable at the build-context root; extra targets go inDockerfile.<purpose>(e.g.Dockerfile.migrate). Select stages with--target, not separate near-duplicate files. - A
.dockerignoreis mandatory and sits next to the Dockerfile. Minimum:.git,**/node_modules,**/target,**/__pycache__,.env*,*.pem,*.key,secrets/,.venv,dist, build caches,README*,.github. A tight ignore file shrinks the context sent to the daemon and prevents secrets/junk from leaking into layers. - Pin the frontend on line 1; pin every
FROMto a specific tag and digest (image:tag@sha256:...). Name every stage (AS build,AS runtime). - Order instructions least→most frequently changing. Group related
RUNs; use heredocs for multi-line scripts. - Keep
ARGs for versions at the top of the stage that uses them; declareARGafterFROM(args before the firstFROMare only visible toFROMlines). - Lint with hadolint in CI (
hadolint Dockerfile) and letdocker build --check(BuildKit's built-in linter) fail the build on warnings.
Multi-stage builds
- Always split build from runtime. Compile/install in a fat builder, then
COPY --from=buildonly the produced artifacts into a minimal final stage. The finalFROMdetermines image size and attack surface. - Copy artifacts, not toolchains: the binary, the
venv/site-packages, thenode_modules(production only), the builtdist/. Never carry compilers, headers,aptlists, or.gitinto runtime. - Use
COPY --from=build --chown=nonroot:nonrootand preferCOPY --linkfor artifact copies —--linkcreates independent layers that stay cache-valid even when earlier layers change, and dedupe across images. - Use dedicated helper stages for dependency resolution or asset builds, and a
basestage for shared setup, so the graph is built in parallel by BuildKit.
# syntax=docker/dockerfile:1
FROM golang:1.26-trixie AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build --chown=nonroot:nonroot /app /app
USER nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Small base images
- Compiled static binaries (Go, Rust static):
gcr.io/distroless/static-debian13:nonroot. Dynamically linked C deps:distroless/base-debian13ordistroless/cc-debian13. - Interpreted:
distroless/java25-debian13(Temurin 25 LTS),distroless/python3-debian13,distroless/nodejs24-debian13(active LTS) — all with the:nonrootvariant. For debugging, swap to the matching:debug-nonroottag (adds a busybox shell) — never ship:debugto production. - Need
apk/aptat runtime:alpine:3.24(musl — watch for glibc-only wheels/binaries) ordebian:trixie-slim. Prefer Wolfi (cgr.dev/chainguard/wolfi-base, glibc,apk) when musl breaks you but you still want minimal + rolling CVE patches. - Pin a digest, never
:latest.:latestis unpinned and non-reproducible; a rebuild months later silently pulls a different image. Resolve the digest withdocker buildx imagetools inspect <image:tag>and commitimage:tag@sha256:.... Automate bumps with Renovate/Dependabot, which update the digest in PRs. - Distroless/DHI images are cosign-signed (keyless) — verify base provenance in CI with
cosign verifybefore you build on top of them.
Layer caching
- Copy dependency manifests and install before copying source, so the expensive install layer is cached until the manifest changes:
# Node — manifests first, frozen install, then source
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
COPY . .
- Use
RUN --mount=type=cachefor package/compiler caches (/root/.npm,/root/.cache/go-build,~/.cargo, pip's~/.cache/pip,/var/cache/apt). Cache mounts persist across builds but are not baked into the image — the best of both worlds. - One logical install per
RUN; combine install + cleanup in the same layer so removed files never persist in a lower layer. For apt:
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends ca-certificates
Using a cache mount means you skip rm -rf /var/lib/apt/lists/*; without the mount you must clean lists in the same RUN.
- Order least→most-changing: base setup → OS packages → language deps → app source → config. Source changes must not invalidate the dependency layer.
- In CI, persist cache across runners with
--cache-to type=registry,ref=...,mode=max+--cache-from, ortype=ghaon GitHub Actions.mode=maxcaches intermediate stages too.
Security
- Run as non-root. Declare
USER nonroot(distroless) or a numeric UID (USER 10001). A numeric UID lets Kubernetes enforcerunAsNonRootwithout a username lookup. Never leave the finalUSERas root. - No secrets in the image. Never
ENV/ARGa token, neverCOPYa.envor key. Build-time secrets useRUN --mount=type=secret; nothing lands in a layer:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,uid=0 \
npm ci --omit=dev
# build: docker build --secret id=npmrc,src=$HOME/.npmrc .
Runtime secrets come from the orchestrator (env, mounted files, secrets manager) — not the image. ARG values are visible in docker history and provenance; treat them as public.
.dockerignoreexcludes secrets and VCS soCOPY . .cannot swallow.git,.env, or keys.- Scan every image in CI and fail on fixable HIGH/CRITICAL:
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed <img>ordocker scout cves --exit-code --only-severity critical,high <img>. - Drop capabilities and go read-only at runtime (compose/k8s, not the Dockerfile):
read_only: true,cap_drop: [ALL],security_opt: [no-new-privileges:true]. Design the image to run with a read-only root filesystem (writable paths viatmpfs/volumes). - Set filesystem ownership at copy time with
COPY --chown; don'tRUN chown -R(it duplicates the whole tree into a new layer). - Sign and attest what you ship:
--provenance=mode=max --sbom=trueon build, thencosign signthe digest.
PID 1, CMD/ENTRYPOINT, HEALTHCHECK
- Exec form only (JSON array):
ENTRYPOINT ["/app"]. Shell form (ENTRYPOINT /app) wraps the process in/bin/sh -c, which becomes PID 1 and swallows SIGTERM/SIGINT — your container ignoresdocker stopand gets SIGKILLed after the grace period. ENTRYPOINTfor the executable,CMDfor default args.ENTRYPOINT ["node","server.js"]+CMD ["--port","8080"]letsdocker run img --port 9090override args cleanly.- PID-1 signal handling / reaping: if your process spawns children or doesn't forward signals, add an init. Use Docker's built-in
docker run --init(tini) at runtime, or bakeENTRYPOINT ["/usr/bin/tini","--","/app"]. A single self-contained binary (Go/Rust) that handles signals needs no init. Distroless ships no init — use--initor tini, never a bash wrapper as PID 1. WORKDIR /app— always set it (absolute); neverRUN cd.WORKDIRcreates the dir and persists across layers.EXPOSE 8080documents the listen port (metadata only — it publishes nothing).HEALTHCHECKwith exec form and tuned timing; distroless has no shell/curl, so use a static probe binary or the app's own health subcommand:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/app","healthcheck"]
In Kubernetes, prefer liveness/readiness probes and you may skip HEALTHCHECK.
Reproducible installs
- Copy and honor the lockfile, and install in frozen/locked mode so a drifted lock fails the build instead of silently resolving new versions:
- npm:
npm ci(requirespackage-lock.json); pnpm:pnpm install --frozen-lockfile; yarn:yarn install --immutable. - Python:
uv sync --frozenorpip install --require-hashes -r requirements.txt(hash-pinned); Poetry:poetry install --no-root --sync. - Go:
go mod downloadwith committedgo.sum; Rust:cargo build --locked.
- npm:
- Pin OS package versions where stability matters (
apt-get install foo=1.2.3). SetSOURCE_DATE_EPOCHand use--output type=image,rewrite-timestamp=truefor byte-reproducible layers when you need attestable builds.
Testing
- Lint:
hadolint Dockerfile+docker build --check .(BuildKit lint pass) gate the PR. - Build in CI on every change with
docker buildx build --load(or--push) so a broken Dockerfile fails fast; build the test/CI target too. - Smoke test the built image, not just the code:
docker run --rm img /app --version, hitHEALTHCHECK, assert it runs as non-root (docker run --rm img id -ureturns non-zero, or inspectConfig.User), assert expected files exist and shells/package managers do not. - Structure tests: use container-structure-test (GoogleContainerTools) or Goss/dgoss to assert metadata (
User,Entrypoint,ExposedPorts), file presence/ownership, and command output. - Scan as a test: Trivy/Scout run in CI with a non-zero exit gate.
- Assert image size / layer count doesn't regress (
docker image inspect --format '{{.Size}}'); usediveto audit wasted space.
Security musts
- Non-root numeric UID in the final stage; read-only rootfs,
cap_drop: [ALL],no-new-privilegesat runtime. - Zero secrets in layers,
ENV,ARG, ordocker history; build secrets via--mount=type=secret, runtime secrets via the orchestrator. - Pinned base by digest,
cosign verify'd; minimal base (distroless/Wolfi/DHI) to shrink attack surface — no shell/package manager in production images. - CI gate on Trivy/Scout for fixable HIGH/CRITICAL; ship SBOM + SLSA provenance and
cosign signthe digest. - Strict
.dockerignoreso context never carries.git/.env/keys.
Do
- Start with
# syntax=docker/dockerfile:1; use BuildKit features (--mount,--link, heredocs). - Multi-stage: fat builder → minimal
distroless/Wolfi/slim runtime;COPY --fromonly artifacts. - Pin
FROMtotag@sha256:...; name every stage; automate digest bumps with Renovate. - Manifests + frozen install before source;
RUN --mount=type=cachefor package caches. USERa numeric non-root UID;COPY --chown; read-only rootfs at runtime.- Exec-form
ENTRYPOINT/CMD;--init/tini for signal reaping;WORKDIR,EXPOSE,HEALTHCHECK. RUN --mount=type=secretfor build secrets; runtime secrets from the orchestrator.--no-install-recommends; clean apt lists in the same layer (or use an apt cache mount).--provenance=mode=max --sbom=true; scan with Trivy/Scout and fail on fixable HIGH/CRITICAL;cosign sign.- Keep a strict
.dockerignore; lint with hadolint +docker build --check.
Avoid
FROM ...:latestor any unpinned tag → pintag@sha256:....:latestis non-reproducible and silently drifts.- Single fat stage shipping compilers, headers, and
.git→ split builder/runtime;COPY --from. - Running as root (no
USER, orUSER root) →USER 10001/nonroot. - Secrets in
ENV/ARG/COPYd files →--mount=type=secret+ runtime env.ARG/ENVare readable viadocker history. apt-get installwithout--no-install-recommends, or leaving/var/lib/apt/listsin a layer → clean in the sameRUNor use a cache mount.ADD https://...for remote files →ADDfor remote URLs is uncached, unverified, and can't check integrity. UseRUN --mount=type=cache curl -fsSL ... | sha256sum -corADD --checksum=sha256:.... UseADDonly for local tar auto-extraction; useCOPYotherwise.- Shell-form
CMD/ENTRYPOINT→ exec form (JSON array); shell form breaks SIGTERM handling. RUN cd /x && ...across instructions →WORKDIR.RUN chown -Ron large trees →COPY --chown.docker-compose(v1) and the legacy non-BuildKit builder →docker composeand buildx.- Alpine for glibc-dependent stacks (native Python wheels, prebuilt binaries) → debian-slim or Wolfi; musl causes subtle runtime failures.
COPY . .early, invalidating the dependency cache on any source edit → copy manifests first, source last.- Baking build-arg "secrets" or embedding
.npmrc/registry tokens into layers → build secrets mount.
When you code
- Keep diffs small and scoped to one Dockerfile/stage; explain why a layer is ordered where it is.
- After editing, run the full loop:
hadolint,docker build --check,docker buildx build, run the image and hit its healthcheck, andtrivy image/docker scout cves. Report image size before/after. - When you add a base image or bump a version, resolve and pin the digest and state the source (Docker Hub / gcr.io / cgr.dev) and why that tag.
- Ask before: switching base-image family (distroless↔alpine↔Wolfi) or a runtime the app links against; adding OS packages to the runtime stage; changing
ENTRYPOINT/USER/exposed ports; enabling registry/GHA cache export (touches CI credentials); publishing/signing images. - Never introduce a secret into build context or layers, never weaken
.dockerignore, and never downgrade the frontend or unpin a base to "make it build" — fix the actual cause.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Docker Engine 29 · BuildKit 0.31 · Dockerfile 1.25 · distroless (Debian 13).