Docker Image Slimming skill for Codex
Cut gigabytes from images without breaking them. Codex measures an image before touching it, using docker history and dive to find the layers that matter, then applies one change at a time: multi-stage builds, slim or distroless bases, cache mounts, production-only dependencies. It states the libc and debuggability trade-off of each base, smoke-tests every platform, and reports before and after sizes, compressed and uncompressed.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- docker-image-slimming Installs to ~/.agents/skills/docker-image-slimming/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install docker-image-slimming The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project docker-image-slimming Installs to .agents/skills/docker-image-slimming/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/docker-image-slimming.zipunzip -o docker-image-slimming.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) docker-image-slimming PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $docker-image-slimming, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Shrinks existing Docker and OCI images by measuring first - docker history, dive layer analysis and wasted-bytes checks - then applying the change with the biggest payoff - multi-stage builds, slim, distroless, Chainguard, Alpine or scratch bases, cache mounts and dependency pruning - while keeping the image debuggable and working. Use when an image is hundreds of MB or several GB, pulls or cold starts are slow, a registry bill is growing, dive reports low efficiency, or the user asks whether to switch to Alpine or distroless.
Use it when
- An image is hundreds of MB or several GB and pulls are slow
- dive reports low efficiency or large wasted bytes
- You are weighing Alpine, slim, distroless or scratch as a base
- Registry storage or egress costs keep growing
Use something else when
- You are writing a new Dockerfile from scratch and want overall structure: use dockerfile-best-practices
- The goal is fixing scanner CVEs rather than size: use container-scanning
How to check the result
Compare docker image ls and dive results before and after, then run the slimmed image's health check on every shipped platform with the production user and entrypoint.
The mistake it prevents
Deleting files in a later RUN step and expecting a smaller image, when the bytes still live in the earlier layer.
Done well means: the size win is measured (before and after, compressed and uncompressed), the image still passes its smoke test on every target platform, and the base-image trade-off (libc, shell, debug tooling, CVE count) was a stated decision.
Measure before changing anything
docker image ls app:current --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' app:current | sort -hr | head -15
docker buildx imagetools inspect ghcr.io/acme/app:1.4.2 # per-platform manifests (network)
dive app:current # interactive layer explorer
CI=true dive app:current # non-interactive pass/fail
dive is a separate install (Homebrew, a release binary, or its container image); in a
network-restricted Codex sandbox ask before installing, and fall back to docker history
plus docker run --rm --entrypoint sh app:current -c 'du -xh / 2>/dev/null | sort -hr | head -30'.
Record a baseline table: total size, the five largest layers and what created them, and
dive’s efficiency score and wasted bytes. Image size shown by docker image ls is
uncompressed; registries and pulls deal in compressed size, typically 30 to 50 percent
of that.
Where the bytes usually are
| Finding in history or dive | Fix | Typical win |
|---|---|---|
Compiler, build-essential, -dev headers in final image | Multi-stage: build in one stage, copy artifacts only | 200 MB to 1 GB |
Full python:3.x / node:22 / golang base | -slim variant, or distroless/Chainguard for runtime | 300 to 800 MB |
apt lists, pip/npm caches in a layer | --mount=type=cache or clean in the same RUN | 20 to 200 MB |
Files deleted in a later RUN | Never delete across layers; do it in the same RUN or a builder stage | equals the deleted files |
node_modules with devDependencies | npm ci --omit=dev in a prod-deps stage | 50 to 500 MB |
Repo .git, tests, fixtures, local .env copied in | .dockerignore | varies; also a leak risk |
| Same big file in two layers (dive “wasted”) | COPY once, chown with COPY --chown not a later RUN chown | doubles disappear |
| CUDA/ML wheels | Pick runtime-only CUDA base, CPU-only wheels where possible | GBs |
RUN chown -R or chmod -R after a COPY duplicates every touched file into a new layer.
Use COPY --chown=app:app and COPY --chmod= instead.
Choose the runtime base
| Base | libc / shell | Use when | Watch for |
|---|---|---|---|
debian:12-slim, python:3.13-slim, node:22-slim | glibc / yes | Default: compatible, small enough, debuggable | Still ships apt, a shell, coreutils |
gcr.io/distroless/static-debian12 | none / no | Static Go or Rust binaries | No shell or libc; CA certs, tzdata and a nonroot user are included |
gcr.io/distroless/base-debian12, cc-debian12 | glibc / no | Dynamically linked binaries | No package manager; add files by multi-stage copy |
| Distroless language images (python3, nodejs, java) | glibc / no | Interpreted apps with no native build needs at runtime | Python version is fixed by Debian, not your choice |
Chainguard / Wolfi images (cgr.dev/chainguard/...) | glibc / -dev tags only | Low-CVE minimal images with current language versions | Free tier offers limited tags; check licensing for pinned versions |
alpine:3.x | musl / busybox | Small images where all deps support musl | musl differences: DNS, locale, threading stack size, slower Python wheels |
scratch | none / no | Fully static binaries | You must add CA bundle, tzdata, /etc/passwd for non-root yourself |
Default recommendation: -slim for interpreted languages, distroless static or scratch
for static Go/Rust. Choose Alpine only after checking every native dependency builds and
runs on musl; “smaller” is not worth a production-only DNS or segfault bug.
Distroless images offer :nonroot tags (UID 65532) and :debug tags with a busybox
shell. Run the :debug tag temporarily when you need to inspect; never ship it.
Patterns
Static Go binary on distroless
# 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
ENTRYPOINT ["/app"]
Python with a virtualenv copied into slim
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS build
RUN python -m venv /venv
ENV PATH=/venv/bin:$PATH
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 app
COPY --from=build /venv /venv
ENV PATH=/venv/bin:$PATH PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY --chown=app:app src/ ./src/
USER app
CMD ["python", "-m", "src.main"]
If a wheel needs compilers, install build-essential only in the build stage. If the
runtime needs a shared library (libpq5), install just that runtime package in the final
stage with apt-get install --no-install-recommends and remove /var/lib/apt/lists/* in
the same RUN.
Node production dependencies
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Frameworks with standalone output (Next.js output: "standalone") let you copy only the
traced files and skip most of node_modules.
Workflow
- [ ] 1. Read AGENTS.md and the Dockerfile; note how CI builds and which platforms ship
- [ ] 2. Baseline: size, top layers, dive efficiency; save numbers
- [ ] 3. Add or fix .dockerignore
- [ ] 4. Apply the largest single fix from the table; rebuild; re-measure
- [ ] 5. Repeat until the next fix saves under ~10 percent or costs debuggability
- [ ] 6. Smoke test every platform; run the container as it runs in production
- [ ] 7. Report before/after and the base-image decision
Change one thing per rebuild so each saving is attributable.
Gotchas
- Moving to distroless or scratch removes the shell:
HEALTHCHECK CMD curl ...,sh -centrypoints anddocker exec -it app shall break. Use exec-formENTRYPOINT, a binary-based health check (or the orchestrator’s HTTP probe), andkubectl debug --image=busybox --target=appfor inspection. - Static Go binaries that use
netoros/userwith cgo enabled are not static. Build withCGO_ENABLED=0or verify withldd(should print “not a dynamic executable”). scratchhas no CA bundle: TLS calls fail with “x509: certificate signed by unknown authority”. Copy/etc/ssl/certs/ca-certificates.crtfrom the build stage.- Alpine Python cannot use manylinux (glibc) wheels; it needs musllinux wheels or a source build, which can make the build slower and the image larger.
-s -wstrips symbols; you lose symbolized panics only for external tools, Go stack traces still work. Do not strip binaries you profile with perf.- Squashing layers reduces layer count but defeats layer sharing between images and cache reuse on pulls. Prefer fixing the Dockerfile.
- Smaller images do not automatically mean fewer CVEs that matter, but fewer packages usually means fewer findings. Run the scanner your CI uses before and after.
Verification
docker build -t app:slim .
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'app:(current|slim)'
CI=true dive app:slim # lowestEfficiency / highestWastedBytes gates
docker run --rm -p 8080:8080 app:slim & # then hit the health endpoint
docker buildx build --platform linux/amd64,linux/arm64 -t app:slim-multi . # if multi-arch
Gate it in CI with a .dive-ci file:
rules:
lowestEfficiency: 0.95
highestWastedBytes: 20MB
highestUserWastedPercent: 0.10
If the smoke test fails after a base switch, check in this order: missing shared library
(ldd), missing CA certs or tzdata, file permissions for the non-root UID, shell-form
commands in the Dockerfile or orchestrator config.
Report template
| Image | Before | After | Change |
|---|---|---|---|
| api (linux/amd64, uncompressed) | 1.21 GB | 148 MB | -88% |
| api (compressed, registry) | 412 MB | 54 MB | -87% |
**Changes:** multi-stage build, python:3.13 -> python:3.13-slim runtime, pip cache mount,
.dockerignore (excludes .git, tests, data/)
**Base decision:** stayed on glibc slim (psycopg binary wheel needs glibc); distroless
rejected because ops relies on `docker exec` for incident triage
**Checks:** dive efficiency 0.98; smoke test /healthz 200 on amd64 and arm64; scanner
findings 143 -> 31
For Dockerfile structure, caching order and security defaults beyond size, use
$dockerfile-best-practices; for CVE triage of the result, use $container-scanning.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Docker Image Slimming"
short_description: "Cut gigabytes from images without breaking them"
default_prompt: "$docker-image-slimming Our Python API image is 1.4 GB, get it under 200 MB without breaking it" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.