DevOps & SRE No. 153

Dockerfile Optimization skill for Claude Code

Smaller images, cached builds, no root, no leaks. Claude baselines image size and warm-build time, runs a bundled audit script, then restructures the Dockerfile into dependency, build and runtime stages with lockfiles copied before source. It picks a distroless or slim base, moves build secrets into BuildKit mounts, drops root, and reports before-and-after size, build time and CVE counts in a table.

  • namedockerfile-optimization
  • size232 lines · ≈2,050 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and audits Dockerfiles for small, secure, cache-friendly images using multi-stage builds, dependency-first layer order, BuildKit cache and secret mounts, non-root users, distroless or slim bases, and a tight .dockerignore. Use when creating a Dockerfile, when an image is too large or slow to build, when every code change reinstalls dependencies, or when a scanner flags root users, latest tags, or leaked secrets.

Try asking

  • Our Node image is 1.2 GB and rebuilds npm ci on every commit. Fix the Dockerfile
  • Write a production Dockerfile for this FastAPI app using uv
  • Trivy says our container runs as root and has an NPM_TOKEN in history. Clean it up

Use it when

  • You are writing a Dockerfile for a new service
  • Builds reinstall dependencies after every source edit
  • The image is hundreds of MB larger than the app needs
  • A scanner flags root users, unpinned bases, or secrets in layers

Skip it when

  • You need to wire several containers together for local dev — use docker-compose instead
  • You are hardening runtime policies, seccomp, or image signing — use container-security instead

How to check the result

A source-only edit rebuilds with the dependency layer marked CACHED, the container starts and serves traffic as a non-root user, and the image scan shows no new HIGH or CRITICAL findings.

The common mistake it prevents

Putting COPY . . above the dependency install step, which throws away the cached install layer on every code change.

~/.claude/skills/dockerfile-optimization/SKILL.md

--- name: dockerfile-optimization description: Writes and audits Dockerfiles for small, secure, cache-friendly images using multi-stage builds, dependency-first layer order, BuildKit… license: MIT ---

A good Dockerfile rebuilds in seconds when only source changes, ships nothing the process does not need at runtime, runs as a non-root user, pins its base image, and contains no secrets in any layer. Measure size and build time before and after.

Ground rules

  • Inspect before rewriting: current image size, layer list, and which step busts cache.
  • One concern per stage: deps -> build -> runtime. The runtime stage copies only artifacts, never the toolchain.
  • Order instructions from least to most frequently changed. Lockfiles before source.
  • Never put credentials in ARG, ENV, or a COPY’d file. Use --mount=type=secret.
  • Pin base images to a specific version tag; pin by digest for production releases.
  • Use exec-form CMD/ENTRYPOINT (["node","server.js"]) so signals reach the process.

Workflow

- [ ] 1. Baseline: size, layers, cold and warm build times
- [ ] 2. Run the audit script and hadolint
- [ ] 3. Write/verify .dockerignore
- [ ] 4. Restructure into multi-stage with dependency-first ordering
- [ ] 5. Pick the runtime base (table below)
- [ ] 6. Harden: non-root, no shell tools if possible, healthcheck, labels
- [ ] 7. Re-measure, scan, and run the container

1. Baseline

docker build -t app:before .                       # cold build, note duration
touch src/index.ts && docker build -t app:before .  # warm build after a source edit
docker image ls app:before --format '{{.Size}}'
docker history --no-trunc app:before               # which layers are fat

dive app:before (if installed) shows wasted space per layer. A warm build that re-runs npm ci/pip install after a source-only change is the first thing to fix.

2. Audit

python3 scripts/dockerfile_audit.py Dockerfile      # bundled, stdlib-only heuristics
docker run --rm -i hadolint/hadolint < Dockerfile   # if Docker is available
docker build --check .                              # BuildKit build checks (Buildx 0.15+)

The audit script exits 1 on error-level findings (unpinned base, root user, secrets in ARG/ENV, source copied before dependency install).

3. .dockerignore

Everything not ignored is sent to the builder and can invalidate COPY . ..

.git
.github
node_modules
dist
build
coverage
.venv
__pycache__
*.pyc
.env
.env.*
!.env.example
Dockerfile*
docker-compose*.yml
*.log
.DS_Store

Check the context size: the transferring context line in BuildKit output should be small (KB to low MB). Hundreds of MB means a missing ignore rule.

4. Multi-stage, dependency-first

Node (npm):

# syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
EXPOSE 3000
CMD ["dist/server.js"]

The distroless Node image’s entrypoint is already node, so CMD lists only the script.

Python (uv):

# syntax=docker/dockerfile:1
FROM python:3.13-slim-bookworm AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /bin/uv
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev

FROM python:3.13-slim-bookworm AS runtime
RUN groupadd --system app && useradd --system --gid app --no-create-home app
WORKDIR /app
COPY --from=build --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
USER app
CMD ["python", "-m", "myapp"]

Pin the uv tag to the version the project uses. With plain pip, copy requirements.txt first and run pip install --no-cache-dir -r requirements.txt.

Go (static binary):

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

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]

5. Pick the runtime base

NeedBaseNotes
Static binary (Go, Rust musl)gcr.io/distroless/static-debian12:nonroot or scratchscratch needs CA certs and tzdata copied in
Node / Python / Java, no shell neededdistroless language image (:nonroot tag)No shell: debug with :debug variant or ephemeral containers
Needs a shell, apt packages, or native libs*-slim-bookworm / *-slim-trixieDefault choice when unsure
Smallest possible with a package managerAlpinemusl breaks some wheels and native modules; test first
Hardened, frequently patched, minimal CVEsChainguard / Docker Hardened ImagesCheck licensing and tag policy

Prefer glibc slim images over Alpine for Python unless every dependency ships musl wheels.

6. Harden

  • Create and switch to a non-root user, or use a :nonroot base. Verify with docker run --rm app id.

  • apt-get in one RUN: apt-get update && apt-get install -y --no-install-recommends pkg && rm -rf /var/lib/apt/lists/*.

  • Use COPY, not ADD, unless extracting a local tarball or using ADD --checksum.

  • Secrets for private registries at build time:

    RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
    docker build --secret id=npmrc,src=$HOME/.npmrc -t app .
  • PID 1 does not reap zombies or forward signals unless the app handles them. Use docker run --init, tini, or make sure the runtime handles SIGTERM.

  • Add OCI labels (org.opencontainers.image.source, .revision) for traceability.

  • HEALTHCHECK only matters for plain Docker/Compose; Kubernetes ignores it and uses probes.

7. Verify

docker build -t app:after .
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep app:
touch src/index.ts && time docker build -t app:after .   # deps layer must be CACHED
docker run --rm -p 3000:3000 app:after                    # app actually starts
docker run --rm --entrypoint id app:after 2>/dev/null || true
trivy image --severity HIGH,CRITICAL app:after            # or: docker scout cves app:after

Done when: warm builds skip dependency install, the container starts and serves, USER is non-root, no HIGH/CRITICAL CVEs introduced, and size is reported.

Gotchas

  • COPY . . above the install step invalidates the dependency layer on every edit.
  • npm install instead of npm ci ignores the lockfile and makes builds non-reproducible.
  • RUN apt-get update in its own layer gets cached forever and later installs use stale indexes.
  • Deleting files in a later RUN does not shrink the image; the earlier layer still holds them.
  • ARG values are visible in docker history; they are not secrets.
  • Multi-arch: build with docker buildx build --platform linux/amd64,linux/arm64; native modules compiled on one arch will crash on the other if copied across.
  • Distroless has no shell: RUN in the runtime stage fails, and CMD must be exec form.
  • .dockerignore is read from the build context root, not next to the Dockerfile (unless using <Dockerfile>.dockerignore).
  • Cache mounts are not part of the image and are not shared across CI runners unless the CI cache backend (--cache-to/--cache-from type=gha or type=registry) is set.

Output

When reporting, include:

| Metric | Before | After |
|---|---|---|
| Image size | 1.21 GB | 148 MB |
| Cold build | 3m40s | 2m55s |
| Warm build (source edit) | 3m10s | 14s |
| Runs as | root | nonroot (65532) |
| HIGH/CRITICAL CVEs | 37 | 2 (no fix available) |

Changes: multi-stage split, lockfile-first COPY, npm cache mount, distroless runtime,
.dockerignore added (context 412 MB -> 1.3 MB).

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/dockerfile_audit.py 145 lines · 6.3 KB
#!/usr/bin/env python3
"""Heuristic Dockerfile audit (stdlib only).

Usage: python3 scripts/dockerfile_audit.py [path/to/Dockerfile]
Exit code 1 if any ERROR findings, 0 otherwise.
"""
import os
import re
import sys

SECRET_NAME = re.compile(r"(PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|PRIVATE_KEY|ACCESS_KEY)", re.I)
DEP_INSTALL = re.compile(
    r"\b(npm (ci|install)|yarn( install)?\b|pnpm install|pip install|uv sync|poetry install|"
    r"go mod download|bundle install|composer install|cargo (build|fetch)|mvn|gradle)"
)


def logical_lines(text):
    """Join backslash continuations, drop comments, keep original line numbers."""
    out, buf, start = [], "", None
    for n, raw in enumerate(text.splitlines(), 1):
        line = raw.rstrip()
        if not buf and line.lstrip().startswith("#"):
            continue
        if start is None:
            start = n
        if line.endswith("\\"):
            buf += line[:-1] + " "
            continue
        buf += line
        if buf.strip():
            out.append((start, buf.strip()))
        buf, start = "", None
    if buf.strip():
        out.append((start, buf.strip()))
    return out


def audit(path):
    with open(path, encoding="utf-8") as fh:
        lines = logical_lines(fh.read())
    findings = []

    def add(level, n, msg):
        findings.append((level, n, msg))

    stages = []  # list of dicts: from, user, copied_all_at, installed_at
    for n, line in lines:
        parts = line.split(None, 1)
        instr = parts[0].upper()
        args = parts[1] if len(parts) > 1 else ""

        if instr == "FROM":
            image = [a for a in args.split() if not a.startswith("--")][0]
            prior_aliases = {s.get("alias") for s in stages}
            stages.append({"from": image, "line": n, "user": None, "copy_all": None, "install": None})
            if image.lower() == "scratch" or image in prior_aliases or "@sha256:" in image or "$" in image:
                pass
            elif ":" not in image.split("/")[-1]:
                add("ERROR", n, f"Base image '{image}' has no tag (implicit :latest). Pin a version.")
            elif image.endswith(":latest"):
                add("ERROR", n, f"Base image '{image}' uses :latest. Pin a version or digest.")
            m = re.search(r"\bAS\s+(\S+)", args, re.I)
            if m:
                stages[-1]["alias"] = m.group(1)
            continue

        if not stages:
            continue
        st = stages[-1]

        if instr == "USER":
            st["user"] = args.strip()
        elif instr in ("ARG", "ENV"):
            name = re.split(r"[=\s]", args.strip(), maxsplit=1)[0]
            if SECRET_NAME.search(name):
                add("ERROR", n, f"{instr} '{name}' looks like a secret; it persists in image metadata. Use RUN --mount=type=secret.")
        elif instr == "ADD":
            if not re.search(r"https?://|--checksum|\.tar|\.tgz|\.tar\.gz", args):
                add("WARN", n, "ADD used for local files; prefer COPY.")
        elif instr == "COPY":
            srcs = [a for a in args.split() if not a.startswith("--")]
            if "--from" not in args and len(srcs) >= 2 and srcs[0] in (".", "./") and st["copy_all"] is None:
                st["copy_all"] = n
        elif instr == "RUN":
            if DEP_INSTALL.search(args) and st["install"] is None:
                st["install"] = n
            if "apt-get install" in args:
                if "--no-install-recommends" not in args:
                    add("WARN", n, "apt-get install without --no-install-recommends.")
                if "/var/lib/apt/lists" not in args:
                    add("WARN", n, "apt lists not removed in the same RUN (rm -rf /var/lib/apt/lists/*).")
                if "apt-get update" not in args:
                    add("WARN", n, "apt-get install without apt-get update in the same RUN (stale cache risk).")
            if re.match(r"^\s*apt-get update\s*$", args):
                add("WARN", n, "apt-get update alone in a RUN gets cached and goes stale.")
            if re.search(r"\bsudo\b", args):
                add("WARN", n, "sudo in a Dockerfile; RUN already executes as the current USER.")
            if re.search(r"\bnpm install\b", args) and "-g" not in args:
                add("WARN", n, "npm install ignores the lockfile; use npm ci.")
            if re.search(r"curl[^|]*\|\s*(sh|bash)", args):
                add("WARN", n, "curl | sh executes unverified remote code; verify a checksum.")
        elif instr in ("CMD", "ENTRYPOINT"):
            if not args.strip().startswith("["):
                add("WARN", n, f"Shell-form {instr}; signals will not reach the process. Use exec form [\"...\"].")

    if not stages:
        add("ERROR", 0, "No FROM instruction found.")
        return findings

    for st in stages:
        if st["copy_all"] and st["install"] and st["copy_all"] < st["install"]:
            add("ERROR", st["copy_all"], "COPY . before dependency install: every source change reinstalls dependencies. Copy lockfiles first.")

    final = stages[-1]
    if final["from"].lower() != "scratch" and "nonroot" not in final["from"]:
        if final["user"] is None or final["user"].split(":")[0] in ("root", "0"):
            add("ERROR", final["line"], "Final stage runs as root. Add a non-root USER or use a :nonroot base.")
    if len(stages) == 1:
        add("INFO", stages[0]["line"], "Single-stage build; consider a separate runtime stage to drop build tools.")

    ctx = os.path.dirname(os.path.abspath(path))
    if not os.path.exists(os.path.join(ctx, ".dockerignore")) and not os.path.exists(path + ".dockerignore"):
        add("WARN", 0, "No .dockerignore next to the Dockerfile (check the real build context root).")
    return findings


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else "Dockerfile"
    if not os.path.isfile(path):
        print(f"error: {path} not found", file=sys.stderr)
        return 2
    findings = audit(path)
    order = {"ERROR": 0, "WARN": 1, "INFO": 2}
    for level, n, msg in sorted(findings, key=lambda f: (order[f[0]], f[1])):
        loc = f"line {n}" if n else "file"
        print(f"{level:5} {loc:>9}  {msg}")
    errors = sum(1 for f in findings if f[0] == "ERROR")
    warns = sum(1 for f in findings if f[0] == "WARN")
    print(f"\n{path}: {errors} error(s), {warns} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc