CLAUDE.md for Docker: 8 Rules That Stop AI From Creating Insecure Containers
You ask Claude to "containerize this app" and it cheerfully ships you a 1.7 GB image based on node:latest, running as root, with ENV API_KEY=... baked into a layer that anyone can pull and inspect. The model isn't broken. You just didn't tell it the rules.
A CLAUDE.md at the root of your repo fixes this once and for all. Claude Code reads it on every task. So do Cursor, Aider, Codex, and any AI that respects context files. Below are 8 rules I drop into every Dockerized repo. Each one closes a class of bug AI assistants generate by default.
Rule 1 — Pin Image Versions, Never :latest
Why: :latest breaks reproducibility. Your CI builds against a different base every week without telling you. Pin by SHA256 digest in production for full immutability.
Bad:
FROM node:latest
FROM postgres:latest
Good:
FROM node:20.11.1-alpine@sha256:f7d6a... AS runner
FROM postgres:16.2-alpine
Rule for CLAUDE.md:
## Image Pinning
- Never `:latest` in any Dockerfile or compose file.
- Application Dockerfiles pin to MAJOR.MINOR.PATCH and a SHA256 digest.
- Document any unpinned tag with a Dockerfile comment explaining why.
Rule 2 — Multi-Stage Builds Are the Default
Why: Single-stage images ship the SDK, dev dependencies, source tree, and package manager into production. A typical Node app weighs ~1.2 GB this way; the same app with two stages drops to ~180 MB.
Bad:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install && npm run build
CMD ["npm", "start"]
Good:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]
For Go, Rust, or Java, push it further — the final stage is scratch or gcr.io/distroless/static.
Rule for CLAUDE.md:
## Multi-Stage Builds
- Every application Dockerfile uses at least two stages: `builder` and final runtime.
- Runtime stage contains only the artifact and runtime libs — no compiler, no source, no test fixtures.
- For compiled languages, final stage is `scratch` or `gcr.io/distroless/*`.
Rule 3 — Run as a Non-Root User
Why: Default container UID is 0 (root). A container escape on a root process means root on the host (or near it, in unprivileged-userns environments). Drop to an unprivileged user and most exploits stall.
Bad:
FROM python:3.12-slim
COPY . /app
CMD ["python", "/app/main.py"] # runs as root
Good:
FROM python:3.12-slim
RUN groupadd -r app && useradd -r -g app -u 10001 app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["python", "main.py"]
For node:* images, a non-root node user (UID 1000) already exists — just USER node.
Rule for CLAUDE.md:
## Non-Root User
- Every Dockerfile ends with `USER <non-root>`.
- If the base image lacks one, create it: `RUN useradd -r -u 10001 app`.
- Never `chmod 777` or `USER root` in the runtime stage.
Rule 4 — Secrets Never Live in ENV, ARG, or Image Layers
Why: This is the single most common security mistake in AI-generated Dockerfiles. ENV API_KEY=... is readable by any process in the container, visible to docker inspect, and baked permanently into the image layer. ARG is worse — it lands in the build cache and docker history shows the value forever.
Bad:
ARG DB_PASSWORD
ENV STRIPE_KEY=sk_live_8a2b...
Good — runtime secrets via compose:
services:
api:
image: myapp:1.4.2
secrets: [stripe_key]
environment:
STRIPE_KEY_FILE: /run/secrets/stripe_key
secrets:
stripe_key:
file: ./secrets/stripe_key.txt
Build-time secrets via BuildKit:
# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
The token never lands in any layer.
Rule for CLAUDE.md:
## Secrets
- Never `ENV SECRET=...` or `ARG SECRET=...` in Dockerfiles.
- Runtime secrets: Docker secrets, AWS Secrets Manager, Vault, or compose `secrets:` block.
- Build-time secrets: BuildKit `--mount=type=secret,id=...` only.
- `.env` is in `.gitignore` from day one. Commit a `.env.example` with placeholders.
Rule 5 — Minimal Base Images
Why: Full ubuntu, debian, python, node images ship hundreds of preinstalled packages your app never uses — each one a CVE waiting to be flagged. -slim, -alpine, and distroless cut the attack surface by 80–95%.
Bad:
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip
Good:
FROM python:3.12-slim AS runner
# or
FROM gcr.io/distroless/python3-debian12 AS runner
For Go binaries, gcr.io/distroless/static-debian12 weighs ~2 MB. There is no shell to exploit and no package manager to escalate from.
Rule for CLAUDE.md:
## Base Images
- Always use the smallest viable variant: `-slim`, `-alpine`, or `distroless`.
- Never the full `ubuntu` / `debian` / `python` / `node` image for application runtimes.
- If `-alpine` (musl) breaks a native dependency, switch to `-slim` (glibc) and add a comment explaining why.
Rule 6 — .dockerignore Is Required and Tight
Why: Without .dockerignore, COPY . . ships your .git/, host-built node_modules/, .env files, IDE configs, and CI logs into the build context. Image bloats, build context upload slows, and secrets leak into layers.
Good — .dockerignore at repo root:
.git
.gitignore
node_modules
.venv
__pycache__
target
dist
build
coverage
.next
.env
.env.*
!.env.example
*.pem
*.key
.idea
.vscode
.DS_Store
*.log
.github
Dockerfile*
docker-compose*.yml
Rule for CLAUDE.md:
## .dockerignore
- Every repo with a Dockerfile has a `.dockerignore` at the repo root.
- Minimum: `.git`, `node_modules`, `.venv`, `__pycache__`, `target`, `dist`, `build`, `coverage`, `.env`, `.env.*`, `!.env.example`, `*.pem`, `*.key`, `.idea`, `.vscode`, `.DS_Store`, `*.log`.
- Prefer explicit `COPY src/ ./src/` over `COPY . .`.
Rule 7 — HEALTHCHECK Is Mandatory
Why: Without HEALTHCHECK, Docker treats a container as healthy the moment the process starts — before it has bound a port or run migrations. So depends_on: [db] only waits for the Postgres container to start, not to accept connections.
Good:
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --spider -q http://localhost:3000/healthz || exit 1
services:
api:
depends_on:
db: { condition: service_healthy }
db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 10s
retries: 5
/healthz must verify what actually matters — DB live, migrations applied — not just return 200 'ok'.
Rule for CLAUDE.md:
## Health Checks
- Every long-running service Dockerfile defines a `HEALTHCHECK`.
- HTTP services check `/healthz` with real readiness (DB, migrations) — not a stub.
- Compose mirrors the healthcheck and uses `depends_on: { condition: service_healthy }`.
Rule 8 — Layer Caching: Least → Most Frequently Changed
Why: Every RUN/COPY creates a content-hashed layer. Wrong order busts the dep-install cache on every commit; right order keeps CI builds at ~20 seconds.
Bad:
COPY . .
RUN npm ci # reinstalls every dependency on every commit
RUN npm run build
Good:
COPY package.json package-lock.json ./
RUN npm ci # cached unless package-lock.json changes
COPY . .
RUN npm run build
Combine apt operations into one RUN so cleanup happens in the same layer:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Rule for CLAUDE.md:
## Layer Caching
- Order least-frequently-changed → most-frequently-changed.
- Copy dependency manifests and install BEFORE copying source.
- Combine `apt-get` / `apk` / `pip` into a single `RUN` with cleanup in the same layer.
- Use BuildKit cache mounts: `RUN --mount=type=cache,target=/root/.npm npm ci`.
How to Use These Rules
- Create a
CLAUDE.mdat your repo root, next toDockerfileanddocker-compose.yml. - Paste the rules — keep what fits, edit what doesn't, add your own conventions.
- Restart Claude Code in the project.
CLAUDE.md is a per-repo contract. Vague rules ("write secure Dockerfiles") get ignored. Concrete rules ("never ENV SECRET=...; secrets are read from /run/secrets/<name>") change every output.
Want the Full Pack?
These 8 rules are a free sample. The full CLAUDE.md Rules Pack has 50+ production-tested rules covering React, TypeScript, Python, FastAPI, Go, Rust, Postgres, Docker, Kubernetes, Terraform, and more.
→ Get the pack on Gumroad — one-time payment, lifetime updates.
Free sample gist: https://gist.github.com/oliviacraft/fba2d0be16303150998fbd3de2af345b
Top comments (0)