agentsclimarketplace

Dockerfile

Skill petr-korobeinikov/skills/skills/dockerfile

Compose, optimize, and fix Dockerfiles for fast, cache-friendly builds and the smallest secure final image — multi-stage, deps-before-source layer caching, and a final base driven toward scratch / distroless / slim. Keeps builds portable when BuildKit is unavailable and secrets out of layers, runs non-root, pins bases, and gates on a linter.From its SKILL.md

Install
npx -y skills add petr-korobeinikov/skills --skill dockerfile

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

23.6 KB, ~6.0k tokens by cl100k_base, as published. Nobody here has run it

dockerfile

A good Dockerfile is judged on three axes at once, and this skill optimizes all three without trading one for another:

  • Fast rebuilds — order layers so a source edit never re-runs dependency installation. Copy the dependency manifest and resolve it before the source.
  • Small, secure final image — a multi-stage build keeps the compiler, SDK, and dev dependencies in a build stage; the final stage carries only the artifact and its runtime. Aim the final FROM at scratch, and step down the ladder only as far as the runtime forces you.
  • Correct and safe — non-root, exec-form entrypoint, no secret ever written into a layer, pinned bases, and a linter as the gate.

Two levers do most of the work: cache (§2) and size (§3). Everything else is hygiene the linter enforces (§6).

Per-language recipes — a complete, current multi-stage Dockerfile for Go, Rust, C/C++, Node, Python, Java, .NET, Ruby, PHP, Elixir — live in references/languages.md. The bad-practice catalog, mapped to the linter rules that flag each, lives in references/anti-patterns.md.

When in doubt, ask the operator

Do not silently rewrite a working build into one you can't verify. If the base image, the runtime's real dependencies, whether a binary is truly static, or whether the target builder has BuildKit is unclear — ask, or verify, before changing it. A Dockerfile that builds smaller but no longer runs, or leaks a token into docker history, is worse than the slow one it replaced. Prefer a change you can build and run over a clever one you can't.

1. First, know your builder — buildx may be absent

Several of the fastest techniques below are BuildKit-only and hard-fail on the legacy builder — so the very first question is what will actually build this file. Don't assume docker buildx.

  • BuildKit is the default for plain docker build only since Docker Engine 23.0 (Feb 2023) on Linux; older engines fall back to the legacy builder.
  • buildx is a separate package (docker-buildx-plugin). A daemon can be BuildKit-capable yet have no buildx plugin, and CI / Podman / Kaniko / Windows-container hosts may have no BuildKit at all.

What hard-fails on the legacy builder (each errors, not degrades): RUN --mount=type=cache|secret|ssh|bind, COPY --link, COPY --chmod, heredocs (RUN <<EOF), and docker build --secret. The # syntax=docker/dockerfile:1 line is silently ignored there — it does not make a cache-mount Dockerfile portable.

What works on every builder (this is the portable foundation): multi-stage builds (since Engine 17.05, 2017), deps-before-source layer ordering, FROM scratch, .dockerignore, COPY --chown, cache cleanup, USER, exec-form ENTRYPOINT/CMD, HEALTHCHECK.

The design rule this imposes: put the whole caching + size win on the portable foundation, and treat BuildKit cache/secret mounts as an accelerator layered on top — never as the thing the build depends on to work. Deps-before-source layer ordering (§2) already caches the common case; cache mounts only speed the "dependencies changed" path.

Detect what's available before reaching for a mount:

docker buildx version                          # present + version, or non-zero if absent
docker version --format '{{.Server.Version}}'  # >= 23.0 => BuildKit default on Linux
docker buildx ls                               # active builder + driver

If BuildKit is present but off: DOCKER_BUILDKIT=1 docker build …. If buildx is genuinely unavailable and can't be installed, either enable BuildKit via the daemon, or emit a BuildKit-free variant that relies only on layer-order caching (no --mount, no --link, no --chmod, no heredocs) — and say so explicitly. There is no in-Dockerfile conditional that toggles cache mounts on and off, so a single file cannot both use RUN --mount=type=cache and build on legacy.

Non-Docker builders: Podman / Buildah do support RUN --mount=type=cache (Buildah 1.24+, shipped in Podman 4.x) and --secret, but not the # syntax= external frontend. Kaniko is archived and supports none of the mounts — prefer rootless BuildKit (buildctl) for daemonless CI.

2. Lever one — cache: order layers cheap→expensive, deps before source

This is the single most important optimization, and it needs no BuildKit.

How the cache decides. Each instruction is one layer with a content-addressed key, and the key folds in every upstream key — so invalidation cascades: once a layer changes, every layer after it re-runs. Two rules follow from how keys are computed:

  • COPY / ADD (and RUN --mount=type=bind) key on a checksum of the copied files' contents, not their mtime. Change a file's bytes → the layer busts; touch its mtime alone → it doesn't.
  • RUN keys on the command string only — not on what the command fetches. RUN apt-get install curl is not re-run next week just because upstream curl changed; force it with a changed earlier layer or --no-cache.

The canonical shape — manifest first, source last:

# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./   # 1. manifest + lockfile only
RUN npm ci                               # 2. cached unless the manifest changes
COPY . .                                 # 3. editing source never busts step 2
RUN npm run build

Order instructions least-frequently-changed first. The dependency install is expensive and stable; source churns constantly — so the install must sit above the source copy, keyed only on the manifest. Every ecosystem has the same two files to copy first:

EcosystemCopy first (manifest + lock)Then install
Gogo.mod go.sumgo mod download
Node (npm)package.json package-lock.jsonnpm ci
Node (pnpm)pnpm-lock.yamlpnpm fetchpnpm install --offline
Python (pip)requirements.txtpip install -r requirements.txt
Python (uv)pyproject.toml uv.lockuv sync --locked --no-install-project
RustCargo.toml Cargo.lockcargo chef cook (see references)
Java (Maven)pom.xml (+ mvnw .mvn)mvn dependency:go-offline
.NET*.csprojdotnet restore
RubyGemfile Gemfile.lockbundle install
PHPcomposer.json composer.lockcomposer install --no-scripts
Elixirmix.exs mix.lockmix deps.get

Full, per-line recipes for each are in references/languages.md.

Cache mounts — the BuildKit accelerator (optional, §1 applies). RUN --mount=type=cache,target=… persists a package-manager / compiler cache across builds. It is never committed to the image layer — so it speeds rebuilds without bloating the image. It helps the dependencies-changed path (re-download only what changed); the plain layer ordering above already handles the unchanged path.

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm      npm ci
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go build -o /bin/app ./cmd/app

Two caveats to state whenever you add one:

  • On ephemeral CI (a fresh runner per job) the mount starts empty every run — --cache-to / --cache-from do not capture it. It pays off on a persistent builder, or via a cache-dance action.

  • apt needs the cache kept and locked, because the default hook deletes .debs and parallel builds corrupt the lists:

    RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
        echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
    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 gcc
    

Without cache mounts, the classic apt one-liner still applies — and update + install must share one RUN (splitting them pairs a stale cached update with a fresh install; this is the most common Dockerfile bug):

RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates \
      curl \
    && rm -rf /var/lib/apt/lists/*

3. Lever two — size: multi-stage, aim the final FROM at scratch

A build toolchain is 10–100× the artifact it produces. Multi-stage keeps it in a build stage; the final stage copies only the artifact and its runtime. Multi-stage works on every builder — it is not a BuildKit feature.

The base-image ladder. Start at scratch; step down only when forced.

RungFinal baseYou getStep down when
0scratchnothing but what you COPYyou'd hand-assemble certs/tz/passwd, or can't produce a static binary
1distroless/static:nonroot (~2 MB)CA certs, tzdata, passwd (uid 65532), nsswitch, /tmpthe binary needs libc (cgo / glibc-dynamic)
2distroless/base:nonroot (base-nossl if no OpenSSL)+ glibc (+ libssl)you need libstdc++/libgcc (C++, glibc-dynamic Rust)
3distroless/cc:nonroot+ libstdc++, libgccyou need a shell / package manager as a first-class feature
4alpinemusl libc, busybox, apkmusl breaks you (glibc-only binaries, Python wheels, DNS quirks)
5debian:trixie-slimglibc, apt, full shellbroadest compatibility, many system deps

Which languages actually reach scratch:

  • Static-compiled → yes. Go (CGO_ENABLED=0), Rust (musl static target), C/C++ (musl-static), Zig — a single statically-linked binary needs nothing else. .NET NativeAOT is the nearest non-static case (lands on runtime-deps chiseled).
  • Interpreted / VM → no. Node, Python, Java, Ruby, PHP, Elixir ship a dynamically-linked interpreter/VM plus its standard library on disk; scratch has no ld.so or libc, so the first exec fails. Their floor is distroless / slim / chiseled — see the per-ecosystem table in references/languages.md.

What a real service needs on scratch (supply each with COPY --from=build, or drop to distroless/static which bundles all of it):

Missing on scratchBreaksFix
CA certificatesoutbound TLS (x509: certificate signed by unknown authority)COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
tzdataLoadLocation, non-UTC timesCOPY … /usr/share/zoneinfo; Go: import _ "time/tzdata"
/etc/passwd, /etc/groupnamed USER, $HOME, user lookupsuse numeric USER 65532:65532, or COPY a minimal passwd
/tmpapps that write temp filescreate in builder + COPY --chmod=1777, or mount a tmpfs at runtime

/etc/resolv.conf, /etc/hosts, /etc/hostname are injected by the runtime into every container including scratch — DNS config is not an image concern. (The old Go/nsswitch "DNS breaks on scratch" gotcha is a pre-Go-1.16 relic.)

Beware the Alpine false economy. alpine's musl libc is not a drop-in for glibc: Python manylinux wheels and most native Node addons are glibc — on musl they compile from source (much slower, larger build stage), and musl's DNS resolver has caused intermittent failures in clusters. For Python / native-heavy stacks, debian-slim (glibc) is usually smaller in practice and far less surprising; Chainguard/Wolfi give glibc at near-Alpine size. Never COPY a musl-linked binary into a glibc image or vice versa.

The canonical illustration — Go to scratch, deps cached, non-root:

# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.25 AS build
ARG TARGETOS TARGETARCH
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=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

The same recipe, BuildKit-free (portable to the legacy builder, §1) — drop the --mount cache mounts and the automatic --platform / TARGETOS / TARGETARCH args (the BuildKit-only parts); the # syntax line is inert on legacy, so dropping it is just tidiness. Deps-before-source layer ordering still caches the common case; what you give up is the cache-mount speedup on the dependencies-changed path and the --platform cross-arch plumbing (a legacy build targets the host arch):

FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/app /app
USER 65532:65532
ENTRYPOINT ["/app"]

4. Secrets and supply chain — never bake a credential into a layer

ARG and ENV are not for secrets. Both persist in the image: ENV is in the config (docker inspect), ARG values are in docker history and in max-mode provenance attestations. A build-arg token is recoverable by anyone with the image. Audit with docker history --no-trunc <image>.

The correct way (BuildKit) — mount the secret for one RUN:

# syntax=docker/dockerfile:1
# The secret mount exposes .npmrc (with the token) at /root/.npmrc for THIS RUN
# only; BuildKit tears the tmpfs down afterward, so it never enters a layer.
# (Contrast `npm config set …_authToken`, which WRITES the token into a real
#  /root/.npmrc that the layer then commits and `docker history` exposes.)
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
docker build --secret id=npmrc,src=$HOME/.npmrc .

For private-repo dependencies, forward the agent, not the key: RUN --mount=type=ssh git clone … with docker build --ssh default.

If BuildKit is unavailable there is no safe build-time equivalentARG leaks. The honest options: enable BuildKit; pass the secret at runtime instead of build time; or do the authenticated work in an intermediate stage and COPY --from only the non-secret artifact forward.

Supply chain: pin the base by immutable digest (FROM img:tag@sha256:…) — a tag is mutable, :latest is the anti-pattern. Pin the frontend (# syntax=docker/dockerfile:1 — floats patch/minor, no breaking changes) on the first line. Emit SBOM / provenance attestations in CI (docker buildx build --sbom=true --provenance=true); note mode=max provenance embeds ARG values, one more reason secrets never go through ARG.

5. Runtime hygiene the image must carry

These are builder-agnostic and belong in almost every Dockerfile; the linter (§6) flags their absence. Detail + the bad→good form for each is in references/anti-patterns.md.

  • Exec-form ENTRYPOINT/CMD (JSON array), never shell form — shell form runs under /bin/sh -c, which is PID 1 and swallows SIGTERM, so docker stop waits out the grace period then SIGKILLs. Pattern: ENTRYPOINT ["app"] + CMD ["--default-arg"].
  • PID 1 / signals — if the app doesn't reap children or handle SIGTERM, add an init: ENTRYPOINT ["tini","--"] / dumb-init, or docker run --init (a runtime flag, useful for distroless).
  • Non-root, numeric USER — e.g. USER 65532:65532. Numeric is required for Kubernetes runAsNonRoot, which validates from image metadata and cannot resolve a username. (The per-base uid choice — the tiers — is in references/languages.md.)
  • WORKDIR, not RUN cd — a cd doesn't persist across RUN layers.
  • .dockerignore — exclude .git, node_modules, build output, .env; it shrinks the context and stops churn-y files from busting COPY . ..
  • HEALTHCHECK where a liveness signal matters (exec-form probe on distroless).
  • COPY --chown to set ownership in the copy layer, not a later chown -R (which doubles a large tree's size). --chmod is BuildKit-only (§1).

6. Lint the file, then scan the image

Static linting catches Dockerfile structure; scanning catches CVEs in the result. They are complementary — run both. Install these CLIs through a version-pinned tool manager such as mise (pin each and record it in the lockfile), rather than unpinned global installs.

hadolint — the primary gate

hadolint parses the Dockerfile AST and runs ShellCheck on RUN scripts, so one pass gives Dockerfile rules (DL####) and shell rules (SC####).

mise use "aqua:hadolint/hadolint@<version>"    # or: docker run --rm -i hadolint/hadolint < Dockerfile
hadolint Dockerfile
hadolint --failure-threshold warning Dockerfile   # gate: exit non-zero at/above warning

Read the findings against the catalog in references/anti-patterns.md, which maps each high-value rule (DL3006/DL3007 pin the base, DL3008/DL3018 pin OS packages, DL3009 clean apt lists, DL3025 exec-form CMD, DL3059 layer sprawl, DL4006 pipefail, DL3002 non-root, DL3020 COPY-not-ADD, …) to its fix. Configure via .hadolint.yaml (failure-threshold, ignored, trustedRegistries — note the key is trustedRegistries, not allowedRegistries); silence a line inline with # hadolint ignore=DL3008. Don't blanket-ignore — each ignore should name why.

droast — optional second, opinionated pass

dockerfile-roast (binary droast) is a standalone Rust linter (its own DF### rule set, not a hadolint wrapper) — a useful independent net. As a Rust CLI it installs through mise's cargo backend (pinned), rather than an unpinned curl | sh bootstrap:

mise use "cargo:dockerfile-roast@<version>"    # binary: droast
droast Dockerfile
droast --preset production --min-severity warning Dockerfile
droast --format sarif Dockerfile               # or: json | github | compact (CI)

Zero-install alternative: docker run --rm -v "$PWD/Dockerfile":/Dockerfile ghcr.io/immanuwell/droast /Dockerfile. Presets: minimal | security | performance | production | strict; exit 0 clean (or with --no-fail), 1 on findings; droast init --from-hadolint .hadolint.yaml migrates an existing config. Treat hadolint as the mature primary gate and droast as a complementary opinion — reconcile, don't blindly obey both.

Scan the built image

  • dive (aqua:wagoodman/dive) — per-layer wasted space and an efficiency score; CI=true dive <image> gates on .dive-ci thresholds.
  • Trivy (aqua:aquasecurity/trivy) — CVEs and Dockerfile misconfig (trivy config ., trivy image --severity HIGH,CRITICAL --exit-code 1).
  • docker scout (docker scout cves, … recommendations) — CVEs + base-image update advice; a Docker CLI plugin, not in aqua.
  • grype (aqua:anchore/grype) — fast CVE scan, consumes Syft SBOMs.

Exit-code gotcha: trivy --exit-code N uses your integer, while docker scout and grype exit 2 on findings — wire non-zero as the gate.

Procedure

Composing a new Dockerfile

  1. Identify the ecosystem and whether the artifact can be a static binary (Go/Rust/C/Zig → aim scratch) or needs a runtime (interpreted/VM → distroless/slim). Pull the matching recipe from references/languages.md.
  2. Check the builder (§1). If BuildKit isn't guaranteed, keep to the portable foundation and drop cache/secret mounts (or ship a BuildKit-free variant).
  3. Write it multi-stage: build stage installs deps manifest-first (§2), then source; final stage is the lowest ladder rung the runtime allows (§3).
  4. Add hygiene (§5): numeric USER, exec-form entrypoint, pinned base + # syntax, and a .dockerignore.
  5. Route any secret through --mount=type=secret (§4), never ARG/ENV.
  6. Lint (§6), build, and run it — confirm it starts and serves, not just that it builds.

Optimizing / fixing an existing Dockerfile

  1. Run hadolint (and optionally droast) first — it names concrete findings.
  2. Reorder to manifest-before-source (§2); the biggest rebuild-time win.
  3. Introduce (or fix) multi-stage and lower the final base one ladder rung at a time (§3), rebuilding and running after each step.
  4. Fold split apt-get update/install, add --no-install-recommends + list cleanup, and pull any ARG/ENV secret onto a secret mount.
  5. Pin the base (digest) and the frontend; add .dockerignore if missing.
  6. Measure: dive for wasted space, docker history --no-trunc for leaks and fat layers, and compare final image size before/after.

Reporting

After composing or fixing, summarize:

  • The build strategy (stages, final base and which ladder rung, and why not lower), and whether it targets BuildKit or the portable foundation.
  • What changed and the expected effect — rebuild-cache behavior (what now caches), final image size before → after.
  • Secrets: how each is passed (secret mount / runtime), and that none is in ARG/ENV/history.
  • Lint status: hadolint (and droast) findings — fixed, or ignored with a named reason — and any image-scan (Trivy/Scout) blockers.
  • Anything the operator must supply at build or run time (build secrets, --init, a tmpfs mount, digest to pin).

What ships with it: 2 files

30.9 KB alongside SKILL.md

references/

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.