Dockerfile Best Practices skill for Codex
Small, cached, non-root images that stop on SIGTERM. Codex writes Dockerfiles that order layers so a source edit never reinstalls dependencies, moves compilers into builder stages, passes registry tokens through BuildKit secret mounts instead of ARG, and ends on a numeric non-root user with an exec-form command. It proves the result by rebuilding after a one-file change, timing docker stop, and running a bundled static audit.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- dockerfile-best-practices Installs to ~/.agents/skills/dockerfile-best-practices/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install dockerfile-best-practices 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 dockerfile-best-practices Installs to .agents/skills/dockerfile-best-practices/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/dockerfile-best-practices.zipunzip -o dockerfile-best-practices.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))) dockerfile-best-practices 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 $dockerfile-best-practices, 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
Writes and reviews production Dockerfiles - multi-stage builds, pinned base images, BuildKit cache and secret mounts, non-root users, exec-form entrypoints, .dockerignore and healthchecks - for Node, Python, Go, Java and static sites. Use when creating a Dockerfile, when builds are slow because every change reinstalls dependencies, when an image is huge or runs as root, when a scanner flags the base image, or when reviewing a Dockerfile in a pull request.
Use it when
- A service needs a new Dockerfile or a rewrite of an old one
- Image builds are slow because dependency layers never cache
- A scanner or reviewer flags root user, latest tags or secrets in build args
- Containers take the full timeout to stop
Use something else when
- The goal is shaving megabytes with distroless or layer analysis: use docker-image-slimming
- You are wiring several containers together for local development: use docker-compose-dev
How to check the result
Rebuild after touching one source file and confirm the dependency install step reports CACHED, then check docker inspect shows a non-root user and docker stop returns in under a second or two.
The mistake it prevents
Copying the whole repository before installing dependencies, which throws away the install layer on every commit.
A good Dockerfile builds reproducibly from a pinned base, rebuilds in seconds when only source code changes, ships only runtime files, runs as a non-root user, and stops cleanly on SIGTERM. Measure each of those; do not assume them.
Before writing anything
- Read
AGENTS.md, the lockfile (package-lock.json,pnpm-lock.yaml,uv.lock,poetry.lock,go.sum) and how the app is started today (Procfile,package.jsonscripts, systemd unit). The container must run the same command. - Check for an existing
Dockerfile,.dockerignore,compose.yamland CI build step. Keep image names, build args and target names that CI already depends on. - Note the runtime needs: native libraries (libpq, libvips), port, writable paths, environment variables, and whether the app handles SIGTERM itself.
- Run
python3 scripts/dockerfile_audit.py Dockerfileon an existing file to get a quick list of problems before editing.
Building and pulling base images needs network and a running Docker daemon. In a Codex sandbox with network off, write and lint the file, then ask the user to run the build or approve network access.
Base image choice
| Need | Default | Notes |
|---|---|---|
| Node app | node:22-bookworm-slim (build) and same slim for runtime | Alpine uses musl; native modules (sharp, bcrypt, prisma engines) break or need rebuilds |
| Python app | python:3.13-slim-bookworm | Use uv or pip wheels in a builder stage; avoid compilers in runtime |
| Go, Rust static binary | build on golang:1.25 / rust:1-slim, run on gcr.io/distroless/static-debian12:nonroot | CGO_ENABLED=0 for Go; distroless has no shell, so no RUN in that stage |
| JVM | eclipse-temurin:21-jre | Use a JRE, not a JDK, at runtime; set -XX:MaxRAMPercentage=75 |
| Static frontend | build in node, serve with nginxinc/nginx-unprivileged:stable-alpine | Listens on 8080 as non-root |
Pin the tag to a minor version at least. For strict reproducibility pin the digest too:
FROM python:3.13-slim-bookworm@sha256:<digest> and let Renovate or Dependabot bump it.
Never ship :latest. Deeper slimming (distroless trade-offs, dive layer analysis) is
covered by $docker-image-slimming.
Layer order for cache hits
Order instructions from least to most frequently changed: base, system packages, dependency manifests, dependency install, source, build. Copying the whole repo before installing dependencies invalidates the install layer on every commit.
# 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 node:22-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./
# uid 1000 is the image's "node" user; numeric so runAsNonRoot can verify it
USER 1000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "dist/server.js"]
Python with uv, compiled wheels only in the builder:
# syntax=docker/dockerfile:1
FROM python:3.13-slim-bookworm AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /usr/local/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 --frozen --no-dev --no-install-project
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.13-slim-bookworm
RUN useradd --system --uid 10001 --no-create-home app
WORKDIR /app
COPY --from=build --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
USER 10001
CMD ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"]
Go to 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"]
Rules
- System packages: one
RUNthat updates, installs and cleans:apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*. A separateRUN apt-get updatelayer gets cached and later installs use stale indexes. - Secrets: never
ARGorENVa token; both persist in image history. UseRUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ciand build withdocker build --secret id=npmrc,src=$HOME/.npmrc .. - User: end with a numeric
USER(e.g.10001) so KubernetesrunAsNonRootcan verify it. Create the user in the runtime stage,--chowncopied files. - Signals: use exec-form
CMD ["node", "server.js"]. Shell form wraps the process in/bin/sh -c, which does not forward SIGTERM, so shutdown waits for the kill timeout. If the app spawns children or cannot reap zombies, addtinior run withdocker run --init. Do not start Node throughnpm startin production; npm does not forward signals reliably. - COPY over ADD.
ADDauto-extracts archives and fetches URLs; use it only when you want exactly that. - Writable paths: apps that write to
/tmpor a cache dir need it to exist and be owned by the runtime user, especially if the platform sets a read-only root filesystem. - Build args:
ARGbeforeFROMis only visible inFROMlines; redeclare it inside the stage to use it. - HEALTHCHECK is honoured by Docker and Compose but ignored by Kubernetes, which uses probes. Distroless images have no shell or curl; use the app binary for the check or skip it.
.dockerignore
Without it the whole repo, including .git and local node_modules, is sent as build
context and COPY . . may overwrite freshly installed dependencies.
.git
**/node_modules
**/__pycache__
.venv
dist
build
coverage
.env
.env.*
!.env.example
*.log
Dockerfile*
compose*.yaml
Workflow
- [ ] 1. Inspect app start command, lockfile, native deps, existing CI build
- [ ] 2. Run scripts/dockerfile_audit.py on the current Dockerfile (if any)
- [ ] 3. Write multi-stage Dockerfile and .dockerignore
- [ ] 4. docker build --check . (build checks, Docker 27+/Buildx 0.15+)
- [ ] 5. Build, then rebuild after touching one source file: deps layer must be CACHED
- [ ] 6. Run the container, hit the health endpoint, docker stop and time the shutdown
- [ ] 7. Inspect size, user and history; scan for CVEs if a scanner is available
Verification
docker build --check . # lint-style build checks
docker build -t app:dev .
touch src/index.ts && docker build -t app:dev . 2>&1 | grep -E "CACHED|npm ci"
docker run --rm -d --name app -p 3000:3000 app:dev
docker inspect --format '{{.Config.User}}' app:dev # must not be empty or root
time docker stop app # should be well under 10 s
docker image ls app:dev --format '{{.Size}}'
docker history --no-trunc app:dev | grep -iE "token|password|secret" || echo "no secrets in history"
Optional when installed: hadolint Dockerfile, trivy image app:dev or
docker scout cves app:dev (scanners download vulnerability databases, so they need network).
If docker stop takes the full 10 seconds, the process is not receiving SIGTERM: check
for shell-form CMD or a wrapper script missing exec "$@".
Audit script
scripts/dockerfile_audit.py is a dependency-free static check for the mistakes above
(unpinned or latest base, missing USER, shell-form CMD, apt-get without cleanup,
secrets in ARG/ENV, COPY . . before dependency install, ADD with URLs, missing
.dockerignore). It exits 1 when it finds errors.
python3 scripts/dockerfile_audit.py path/to/Dockerfile
Output
When delivering, report:
**Image:** app:dev, 142 MB (was 1.1 GB), runtime base node:22-bookworm-slim
**Stages:** deps -> build -> runtime; deps layer cached on source-only change (verified)
**Runs as:** uid 1000 (node); stops in 0.4 s on SIGTERM
**Checks:** docker build --check clean; audit script clean; trivy: 0 critical, 3 high in base (no fix available)
**Follow-ups:** pin base digest via Renovate; app writes to /app/uploads, needs a volume What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Dockerfile Best Practices"
short_description: "Small, cached, non-root images that stop on SIGTERM"
default_prompt: "$dockerfile-best-practices Write a production Dockerfile for this Next.js app" scripts/dockerfile_audit.py
#!/usr/bin/env python3
"""Static audit of a Dockerfile for common production mistakes.
Usage: python3 dockerfile_audit.py path/to/Dockerfile
Exit code: 0 = no errors (warnings allowed), 1 = errors found, 2 = usage problem.
Standard library only.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
SECRET_WORDS = re.compile(r"(TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY|ACCESS_KEY)", re.I)
INSTALL_CMDS = re.compile(
r"\b(npm (ci|install)|pnpm install|yarn( install)?\b|pip install|uv sync|poetry install|"
r"go mod download|bundle install|cargo (build|fetch)|composer install|mvn |gradle )"
)
def logical_lines(text: str) -> list[tuple[int, str]]:
"""Join backslash continuations; return (starting line number, instruction)."""
out: list[tuple[int, str]] = []
buf, start = "", 0
heredoc_end: str | None = None
for n, raw in enumerate(text.splitlines(), start=1):
line = raw.rstrip()
if heredoc_end is not None:
buf += "\n" + line
if line.strip() == heredoc_end:
out.append((start, buf))
buf, heredoc_end = "", None
continue
if not buf and (not line.strip() or line.lstrip().startswith("#")):
continue
if not buf:
start = n
if line.endswith("\\"):
buf += line[:-1] + " "
continue
buf += line
m = re.search(r"<<-?['\"]?(\w+)['\"]?", buf)
if m and buf.split(None, 1)[0].upper() in {"RUN", "COPY"}:
heredoc_end = m.group(1)
continue
out.append((start, buf))
buf = ""
if buf:
out.append((start, buf))
return out
def audit(path: Path) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
lines = logical_lines(path.read_text(encoding="utf-8"))
stages = 0
stage_names: set[str] = set()
last_user: str | None = None
copied_all_at = None
final_cmd_shell_form = False
for n, instr in lines:
parts = instr.split(None, 1)
op = parts[0].upper()
args = parts[1] if len(parts) > 1 else ""
if op == "FROM":
stages += 1
last_user = None
copied_all_at = None
tokens = [t for t in args.split() if not t.startswith("--")]
image = tokens[0] if tokens else ""
if len(tokens) >= 3 and tokens[1].upper() == "AS":
stage_names.add(tokens[2].lower())
if image.lower() in stage_names or image == "scratch" or image.startswith("$"):
continue
if "@sha256:" in image:
continue
name_part = image.rsplit("/", 1)[-1]
if ":" not in name_part:
errors.append(f"line {n}: base image '{image}' has no tag (implicit :latest)")
elif name_part.endswith(":latest"):
errors.append(f"line {n}: base image '{image}' uses :latest")
else:
warnings.append(f"line {n}: '{image}' is tag-pinned but not digest-pinned")
elif op == "USER":
last_user = args.strip()
elif op in {"ARG", "ENV"}:
key = re.split(r"[=\s]", args.strip(), maxsplit=1)[0]
if SECRET_WORDS.search(key):
errors.append(f"line {n}: {op} {key} looks like a secret; use RUN --mount=type=secret")
elif op == "ADD":
if re.search(r"https?://", args):
warnings.append(f"line {n}: ADD with a URL; prefer RUN curl with checksum or COPY")
elif not re.search(r"\.(tar|tgz|tar\.gz|tar\.xz|tar\.bz2)\b", args):
warnings.append(f"line {n}: ADD used for plain files; use COPY")
elif op == "COPY":
srcs = [t for t in args.split() if not t.startswith("--")]
if "--from" not in args and srcs[:1] == ["."]:
copied_all_at = n
elif op == "RUN":
if "apt-get install" in args:
if "--no-install-recommends" not in args:
warnings.append(f"line {n}: apt-get install without --no-install-recommends")
if "rm -rf /var/lib/apt/lists" not in args and "type=cache" not in args:
warnings.append(f"line {n}: apt lists not removed in the same RUN")
if "apt-get update" not in args:
errors.append(f"line {n}: apt-get install without apt-get update in the same RUN")
elif re.search(r"apt-get update\s*$", args.strip()):
errors.append(f"line {n}: apt-get update alone in a RUN layer goes stale in cache")
if "apk add" in args and "--no-cache" not in args:
warnings.append(f"line {n}: apk add without --no-cache")
if INSTALL_CMDS.search(args) and copied_all_at is not None:
warnings.append(
f"line {n}: dependency install runs after 'COPY . .' (line {copied_all_at}); "
"copy manifests first so the install layer stays cached"
)
if re.search(r"curl[^|]*\|\s*(ba)?sh", args):
warnings.append(f"line {n}: piping curl into a shell; verify a checksum instead")
elif op in {"CMD", "ENTRYPOINT"}:
final_cmd_shell_form = not args.strip().startswith("[")
if final_cmd_shell_form:
warnings.append(f"line {n}: {op} in shell form; SIGTERM will not reach the app")
if re.search(r"\bnpm (start|run)\b", args):
warnings.append(f"line {n}: starting via npm; run node directly so signals arrive")
if stages == 0:
errors.append("no FROM instruction found")
if last_user is None or last_user.split(":")[0] in {"root", "0"}:
errors.append("final stage runs as root; add a non-root USER (numeric uid preferred)")
elif not last_user.split(":")[0].isdigit():
warnings.append(f"final USER '{last_user}' is a name; a numeric uid lets runAsNonRoot verify it")
if not (path.parent / ".dockerignore").exists() and not Path(str(path) + ".dockerignore").exists():
warnings.append("no .dockerignore next to the Dockerfile; .git and node_modules enter the context")
if stages == 1:
warnings.append("single-stage build; build tools and sources likely ship in the image")
return errors, warnings
def main() -> int:
if len(sys.argv) != 2:
print(__doc__.strip(), file=sys.stderr)
return 2
path = Path(sys.argv[1])
if not path.is_file():
print(f"not a file: {path}", file=sys.stderr)
return 2
errors, warnings = audit(path)
for e in errors:
print(f"ERROR {e}")
for w in warnings:
print(f"WARN {w}")
print(f"\n{path}: {len(errors)} error(s), {len(warnings)} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
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.