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.
- name
dockerfile-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.
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 aCOPY’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
| Need | Base | Notes |
|---|---|---|
| Static binary (Go, Rust musl) | gcr.io/distroless/static-debian12:nonroot or scratch | scratch needs CA certs and tzdata copied in |
| Node / Python / Java, no shell needed | distroless language image (:nonroot tag) | No shell: debug with :debug variant or ephemeral containers |
| Needs a shell, apt packages, or native libs | *-slim-bookworm / *-slim-trixie | Default choice when unsure |
| Smallest possible with a package manager | Alpine | musl breaks some wheels and native modules; test first |
| Hardened, frequently patched, minimal CVEs | Chainguard / Docker Hardened Images | Check 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
:nonrootbase. Verify withdocker run --rm app id. -
apt-getin oneRUN:apt-get update && apt-get install -y --no-install-recommends pkg && rm -rf /var/lib/apt/lists/*. -
Use
COPY, notADD, unless extracting a local tarball or usingADD --checksum. -
Secrets for private registries at build time:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm cidocker 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. -
HEALTHCHECKonly 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 installinstead ofnpm ciignores the lockfile and makes builds non-reproducible.RUN apt-get updatein its own layer gets cached forever and later installs use stale indexes.- Deleting files in a later
RUNdoes not shrink the image; the earlier layer still holds them. ARGvalues are visible indocker 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:
RUNin the runtime stage fails, andCMDmust be exec form. .dockerignoreis 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=ghaortype=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
#!/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.