DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Docker: 8 Rules That Stop AI From Creating Insecure Containers

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
Enter fullscreen mode Exit fullscreen mode

Good:

FROM node:20.11.1-alpine@sha256:f7d6a... AS runner
FROM postgres:16.2-alpine
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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/*`.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Good:

FROM python:3.12-slim AS runner
# or
FROM gcr.io/distroless/python3-debian12 AS runner
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 . .`.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

/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 }`.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Good:

COPY package.json package-lock.json ./
RUN npm ci          # cached unless package-lock.json changes
COPY . .
RUN npm run build
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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`.
Enter fullscreen mode Exit fullscreen mode

How to Use These Rules

  1. Create a CLAUDE.md at your repo root, next to Dockerfile and docker-compose.yml.
  2. Paste the rules — keep what fits, edit what doesn't, add your own conventions.
  3. 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)