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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- dockerfile-best-practices

Installs to ~/.agents/skills/dockerfile-best-practices/ for every project on this machine.

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

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.

~/.agents/skills/dockerfile-best-practices/SKILL.md

--- name: dockerfile-best-practices description: Writes and reviews production Dockerfiles - multi-stage builds, pinned base images, BuildKit cache and secret mounts, non-root users, exec-form… license: MIT ---

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

  1. 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.json scripts, systemd unit). The container must run the same command.
  2. Check for an existing Dockerfile, .dockerignore, compose.yaml and CI build step. Keep image names, build args and target names that CI already depends on.
  3. Note the runtime needs: native libraries (libpq, libvips), port, writable paths, environment variables, and whether the app handles SIGTERM itself.
  4. Run python3 scripts/dockerfile_audit.py Dockerfile on 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

NeedDefaultNotes
Node appnode:22-bookworm-slim (build) and same slim for runtimeAlpine uses musl; native modules (sharp, bcrypt, prisma engines) break or need rebuilds
Python apppython:3.13-slim-bookwormUse uv or pip wheels in a builder stage; avoid compilers in runtime
Go, Rust static binarybuild on golang:1.25 / rust:1-slim, run on gcr.io/distroless/static-debian12:nonrootCGO_ENABLED=0 for Go; distroless has no shell, so no RUN in that stage
JVMeclipse-temurin:21-jreUse a JRE, not a JDK, at runtime; set -XX:MaxRAMPercentage=75
Static frontendbuild in node, serve with nginxinc/nginx-unprivileged:stable-alpineListens 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 RUN that updates, installs and cleans: apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*. A separate RUN apt-get update layer gets cached and later installs use stale indexes.
  • Secrets: never ARG or ENV a token; both persist in image history. Use RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci and build with docker build --secret id=npmrc,src=$HOME/.npmrc ..
  • User: end with a numeric USER (e.g. 10001) so Kubernetes runAsNonRoot can verify it. Create the user in the runtime stage, --chown copied 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, add tini or run with docker run --init. Do not start Node through npm start in production; npm does not forward signals reliably.
  • COPY over ADD. ADD auto-extracts archives and fetches URLs; use it only when you want exactly that.
  • Writable paths: apps that write to /tmp or 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: ARG before FROM is only visible in FROM lines; 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.yamlgenerated from this page

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.py165 lines, 6.8 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem