agentsclimarketplace

Dockerfile

Skill petr-korobeinikov/skills/skills/dockerfile

Claude Code agent skills. Plain markdown. Highly opinionated.

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

  • 0 stars0 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.

What its author says it does

Copied from the file, not written here

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.

SKILL.md

23.6 KB, 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).

Keep looking

Skills are one crate of 328,083. 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.