A ready-to-use Claude skill that audits a Dockerfile and rewrites it to be smaller, faster to build, and more secure, without changing how the app runs. Multi-stage builds, the right base, cache-friendly layers, BuildKit cache mounts, and hardening.
Most production Dockerfiles carry weight they never needed: the compiler that built the app, dev dependencies, package-manager caches, the entire OS userland. The result is a multi-hundred-megabyte image that builds slowly and ships a wide attack surface.
Fixing it is mechanical but fiddly, you have to restructure the build, pick a leaner base without breaking a native dependency, and reorder layers so the cache actually helps. That is exactly the kind of rule-driven rewrite an LLM does well. Below is a packaged Claude skill that does it, followed by how it thinks so you can trust its output.
It treats optimization as a contract problem, not a find-and-replace. First it figures out what the image must keep doing: the start command, the ports, the environment, the files and system libraries the app needs at runtime. Then it makes everything else as small as possible.
The core moves it applies:
-slim, to -alpine, to distroless/scratch, while watching for the tradeoffs (musl vs glibc, no shell in distroless)..dockerignore, and copy only what the runtime needs instead of the whole tree.It returns the rewritten Dockerfile, a .dockerignore if one is missing, a short change log explaining each decision, and the commands to verify the result.
Save this as SKILL.md. The description is written so Claude reaches for it at the right moment.
---
name: optimize-docker-images
description: >-
Audit and rewrite Dockerfiles to produce smaller, faster-building, and more
secure images without changing runtime behavior. Use when the user shares a
Dockerfile, asks to optimize / shrink / reduce / speed up a Docker image or
build, reports a large image or slow build or cache misses, or asks about
multi-stage builds, base image choice (slim, alpine, distroless, scratch),
layer caching and ordering, BuildKit cache mounts, .dockerignore, non-root
containers, or pinning image tags.
---
# Optimize Docker Images
You rewrite Dockerfiles to minimize image size and build time and to reduce
attack surface, while keeping runtime behavior byte-for-byte equivalent.
## Method
1. **Read the project, not just the Dockerfile.** Identify the language and
package manager, how the app is built, how it starts (entrypoint/cmd), the
ports it exposes, the env vars and files it needs, and any native or system
dependencies.
2. **Write down the runtime contract** you must preserve: start command, ports,
env, required files, and required system libraries. Never break it to save
size.
3. **Restructure into stages.** A `build` stage with the full toolchain, and a
minimal final stage that receives only the finished artifact via
`COPY --from=build`.
4. **Pick the leanest viable base** (see the ladder below).
5. **Optimize the layer graph** for caching and minimize layer count.
6. **Harden** the final image.
7. **Output** the rewritten Dockerfile, a `.dockerignore` if missing, a bullet
change log (each change + why), and verification commands.
## The levers
**Multi-stage separation.** Build with dev dependencies and compilers in an
early stage; copy only the runtime artifact (binary, `dist/`, wheels) into the
final stage so none of the build machinery ships.
**Base image ladder.** Step down until something breaks, then stop one rung up:
`full` (build tools, ~1GB class) -> `-slim` (Debian, glibc, no build tools) ->
`-alpine` (musl libc, very small) -> `distroless` (no shell, no package
manager) -> `scratch` (nothing). Notes:
- Alpine uses musl, not glibc. Native modules and some prebuilt binaries can
break; if they do, use `-slim` instead.
- `distroless` and `scratch` have no shell. Do not use them if the app needs a
shell, a shell-form `HEALTHCHECK`, or runtime package installs. They are ideal
for static binaries (Go, Rust) and self-contained runtimes.
**Cache-friendly layer order.** Copy dependency manifests and install
dependencies before copying application source. Dependencies change rarely;
source changes every commit. This keeps the expensive install layer cached.
**BuildKit cache mounts.** For package managers, mount a cache so downloads
persist across builds without bloating the image:
`RUN --mount=type=cache,target=/root/.npm npm ci` (npm),
`/var/cache/apt` and `/var/lib/apt` for apt, `/root/.cache/pip` for pip,
`/go/pkg/mod` for Go modules.
**Minimize and self-clean layers.** Each `RUN` is a layer; cleanup in a later
layer does not shrink an earlier one. Update, install, and remove caches in a
single `RUN`, and prefer `--no-install-recommends` for apt.
**Tight build context.** Add a `.dockerignore` (node_modules, .git, .env, logs,
build output, tests, the Dockerfile itself). In the final stage, copy only what
the runtime needs rather than `COPY . .`.
**Hardening.** Run as a non-root user (create one if needed and `USER` to it).
Pin the base image to an exact, immutable tag, or a digest for full
reproducibility. Never bake secrets via `ARG`/`ENV`. Keep `EXPOSE` accurate, and
add a `HEALTHCHECK` only if the final base has the tooling to run it.
## Language playbooks
- **Go / Rust:** build a static binary in the build stage, ship it on
`distroless/static` or `scratch`. Final images can be single-digit MB.
- **Node:** build stage runs `npm ci` then the build; final stage runs
`npm ci --omit=dev` and copies only the build output. Use `node:*-alpine` or
`node:*-slim`.
- **Python:** install into a virtualenv (or use `--user`) in the build stage and
copy it into a `python:*-slim` final stage. Avoid alpine for Python unless you
enjoy compiling wheels; `-slim` is usually the sweet spot.
- **Java:** build the jar with Maven/Gradle in the build stage, run it on a JRE
(not JDK) base, ideally a `jlink` custom runtime or a distroless Java image.
## Anti-patterns to fix on sight
- `FROM ...:latest` or any floating tag.
- Running as root (no `USER`).
- `COPY . .` before installing dependencies (busts the cache every commit).
- `apt-get install` without `--no-install-recommends` or without cleaning
`/var/lib/apt/lists` in the same layer.
- Build tools (gcc, make, dev headers) present in the final image.
- Secrets passed via `ARG`/`ENV` (they persist in image history).
- A single giant stage that both builds and runs.
## Output format
- The full optimized `Dockerfile` in a code block.
- A `.dockerignore` if the project lacks one.
- A bullet change log: each change with its size / speed / security reason, and
a rough before/after size expectation.
- The exact verify commands.
## Guardrails
- Correctness first. If a leaner base or a dropped package breaks the app, keep
the app working and explain the tradeoff.
- Preserve the runtime contract exactly: start command, ports, env, required
files, and system libraries.
- If you cannot determine the build or start command from the repository, ask
rather than guess.
- Prefer changes you can justify; do not cargo-cult flags that do not apply to
this project.
Claude Code: create .claude/skills/optimize-docker-images/SKILL.md in your repo (or ~/.claude/skills/optimize-docker-images/SKILL.md to make it global), paste the skill, and Claude loads it automatically. Open a project with a heavy image and ask Claude to optimize the Dockerfile.
Claude.ai and other tools: add the same SKILL.md as a project skill or paste it into your project knowledge. Keep the description intact, that is what triggers it.
Optimization is only real if the image still runs and actually shrank:
DOCKER_BUILDKIT=1 docker build -t myapp:new .
docker run --rm myapp:new # confirm it still starts and serves
docker images myapp # compare new size against the old
docker scout quickview myapp:new # optional: check vulnerabilities
Read the change log like a pull request from a teammate, run the build, then ship it.