Promptheus/rules53 rule sets · CC0Promptheus hub ↗

DevOps · Docker Engine 29 · BuildKit 0.31 · Dockerfile 1.25 · distroless (Debian 13)

Docker

Small, cached, non-root images — production Dockerfiles.

dockercontainersdevops

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 buildx ships 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 :1 tag floats to the latest stable 1.x frontend at build time, so --mount, --link, heredocs, and COPY --exclude are always available regardless of the daemon version.
  • Compose v2 (docker compose, the plugin). The v1 docker-compose Python 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) or alpine:3.24 when you need a shell/package manager at runtime.
  • Scanning: Trivy ≥ 0.70 (0.72.x current; avoid the compromised 0.69.40.69.6 tags) and/or Docker Scout (docker scout cves). Sign with cosign (keyless/Sigstore).
  • Attestations: build with --provenance=mode=max --sbom=true so images carry SLSA provenance and an SBOM.

Project conventions

  • One Dockerfile per deployable at the build-context root; extra targets go in Dockerfile.<purpose> (e.g. Dockerfile.migrate). Select stages with --target, not separate near-duplicate files.
  • A .dockerignore is 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 FROM to 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; declare ARG after FROM (args before the first FROM are only visible to FROM lines).
  • Lint with hadolint in CI (hadolint Dockerfile) and let docker 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=build only the produced artifacts into a minimal final stage. The final FROM determines image size and attack surface.
  • Copy artifacts, not toolchains: the binary, the venv/site-packages, the node_modules (production only), the built dist/. Never carry compilers, headers, apt lists, or .git into runtime.
  • Use COPY --from=build --chown=nonroot:nonroot and prefer COPY --link for artifact copies — --link creates 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 base stage 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-debian13 or distroless/cc-debian13.
  • Interpreted: distroless/java25-debian13 (Temurin 25 LTS), distroless/python3-debian13, distroless/nodejs24-debian13 (active LTS) — all with the :nonroot variant. For debugging, swap to the matching :debug-nonroot tag (adds a busybox shell) — never ship :debug to production.
  • Need apk/apt at runtime: alpine:3.24 (musl — watch for glibc-only wheels/binaries) or debian: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. :latest is unpinned and non-reproducible; a rebuild months later silently pulls a different image. Resolve the digest with docker buildx imagetools inspect <image:tag> and commit image: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 verify before 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=cache for 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, or type=gha on GitHub Actions. mode=max caches intermediate stages too.

Security

  • Run as non-root. Declare USER nonroot (distroless) or a numeric UID (USER 10001). A numeric UID lets Kubernetes enforce runAsNonRoot without a username lookup. Never leave the final USER as root.
  • No secrets in the image. Never ENV/ARG a token, never COPY a .env or key. Build-time secrets use RUN --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.

  • .dockerignore excludes secrets and VCS so COPY . . 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> or docker 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 via tmpfs/volumes).
  • Set filesystem ownership at copy time with COPY --chown; don't RUN chown -R (it duplicates the whole tree into a new layer).
  • Sign and attest what you ship: --provenance=mode=max --sbom=true on build, then cosign sign the 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 ignores docker stop and gets SIGKILLed after the grace period.
  • ENTRYPOINT for the executable, CMD for default args. ENTRYPOINT ["node","server.js"] + CMD ["--port","8080"] lets docker run img --port 9090 override 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 bake ENTRYPOINT ["/usr/bin/tini","--","/app"]. A single self-contained binary (Go/Rust) that handles signals needs no init. Distroless ships no init — use --init or tini, never a bash wrapper as PID 1.
  • WORKDIR /app — always set it (absolute); never RUN cd. WORKDIR creates the dir and persists across layers.
  • EXPOSE 8080 documents the listen port (metadata only — it publishes nothing).
  • HEALTHCHECK with 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 (requires package-lock.json); pnpm: pnpm install --frozen-lockfile; yarn: yarn install --immutable.
    • Python: uv sync --frozen or pip install --require-hashes -r requirements.txt (hash-pinned); Poetry: poetry install --no-root --sync.
    • Go: go mod download with committed go.sum; Rust: cargo build --locked.
  • Pin OS package versions where stability matters (apt-get install foo=1.2.3). Set SOURCE_DATE_EPOCH and use --output type=image,rewrite-timestamp=true for 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, hit HEALTHCHECK, assert it runs as non-root (docker run --rm img id -u returns non-zero, or inspect Config.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}}'); use dive to audit wasted space.

Security musts

  • Non-root numeric UID in the final stage; read-only rootfs, cap_drop: [ALL], no-new-privileges at runtime.
  • Zero secrets in layers, ENV, ARG, or docker 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 sign the digest.
  • Strict .dockerignore so 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 --from only artifacts.
  • Pin FROM to tag@sha256:...; name every stage; automate digest bumps with Renovate.
  • Manifests + frozen install before source; RUN --mount=type=cache for package caches.
  • USER a 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=secret for 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 ...:latest or any unpinned tag → pin tag@sha256:.... :latest is non-reproducible and silently drifts.
  • Single fat stage shipping compilers, headers, and .git → split builder/runtime; COPY --from.
  • Running as root (no USER, or USER root) → USER 10001/nonroot.
  • Secrets in ENV/ARG/COPYd files--mount=type=secret + runtime env. ARG/ENV are readable via docker history.
  • apt-get install without --no-install-recommends, or leaving /var/lib/apt/lists in a layer → clean in the same RUN or use a cache mount.
  • ADD https://... for remote files → ADD for remote URLs is uncached, unverified, and can't check integrity. Use RUN --mount=type=cache curl -fsSL ... | sha256sum -c or ADD --checksum=sha256:.... Use ADD only for local tar auto-extraction; use COPY otherwise.
  • Shell-form CMD/ENTRYPOINT → exec form (JSON array); shell form breaks SIGTERM handling.
  • RUN cd /x && ... across instructions → WORKDIR. RUN chown -R on large trees → COPY --chown.
  • docker-compose (v1) and the legacy non-BuildKit builder → docker compose and 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, and trivy 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).

Back to top ↑