Cursor Rules for Docker: The Complete Guide to AI-Assisted Docker Development
Docker is the tool that lets you ship a running container in ten minutes and a 2 GB production image with your SSH keys embedded in layer 3 by the end of the week. The first regression is almost always the Dockerfile that starts FROM node:latest, runs COPY . . before npm install, installs gcc, make, python3, every build dependency you need, and never removes them — shipping a 1.8 GB Node image where 1.4 GB is compilers that a running app never uses. The second is the image that runs as root with CMD ["npm", "start"] and an exposed SSH port "for debugging." The third is the developer who copies a .env with real AWS keys into the build context, and the keys end up in a public layer on Docker Hub, scraped within six minutes by a credential harvester.
Then you add an AI assistant.
Cursor and Claude Code were trained on Dockerfiles that span a decade — FROM ubuntu:14.04 and hand-installed everything, the RUN apt-get update && apt-get install -y ... with no --no-install-recommends and no rm -rf /var/lib/apt/lists/*, the ADD directive used for remote URLs (Dockerfile 1.0 style), EXPOSE 22 for SSH, MAINTAINER (deprecated since 2017), CMD npm install && npm start because the AI is conflating build-time and run-time, a single-stage image with dev dependencies in production, and "best practices" from 2016 that pre-date BuildKit, multi-stage builds, and OCI image signing. Ask for "a Dockerfile for a Node app," and you get a single-stage FROM node, COPY . ., RUN npm install, CMD npm start, running as root, weighing 1.6 GB, with your entire .git history and CI logs baked in. It runs. It is not the image you should ship in 2026.
The fix is .cursorrules — one file in the repo that tells the AI what idiomatic modern Docker looks like. Eight rules below, each with the failure mode, the rule, and a before/after. Copy-paste .cursorrules at the end.
How Cursor Rules Work for Docker Projects
Cursor reads project rules from two locations: .cursorrules (a single file at the repo root, still supported) and .cursor/rules/*.mdc (modular files with frontmatter, recommended). For Docker I recommend modular rules so build-time and runtime concerns don't get tangled, and so Compose conventions stay separate from production Dockerfiles:
.cursor/rules/
dockerfile-base.mdc # FROM pinning, multi-stage, layer order
dockerfile-security.mdc # non-root, read-only rootfs, secrets
dockerfile-runtime.mdc # HEALTHCHECK, STOPSIGNAL, ENTRYPOINT vs CMD
dockerignore.mdc # what NEVER goes in the build context
docker-compose.mdc # dev-only patterns, healthchecks, depends_on
docker-supply-chain.mdc # digests, scanning, signing, SBOM
Frontmatter controls activation: globs: ["**/Dockerfile*", "**/docker-compose*.yml", "**/.dockerignore"] with alwaysApply: false. Now the rules.
Rule 1: Multi-Stage Builds — Build Artifacts Separate From Runtime, Minimal Final Image
The single biggest Docker failure AI reproduces is "one stage for everything." Cursor writes a Dockerfile where the same image installs the compiler, compiles the code, runs the tests, and then runs the app — shipping 1.5 GB of build dependencies into production. Multi-stage builds (introduced in Docker 17.05, 2017, still underused) let you use a heavy image for building and copy only the artifact into a minimal runtime image. Combined with distroless or -alpine runtime bases, the final image is often 80–95% smaller.
The rule:
Every production Dockerfile is multi-stage. Minimum two stages:
- `builder`: has compilers, dev deps, source code; produces artifacts.
- `runtime`: minimal base, copies only the artifacts, runs the app.
Runtime bases, in descending preference:
- `gcr.io/distroless/*` (Google distroless: no shell, no package
manager, ~20 MB base). Best for compiled langs (Go, Java, .NET,
Rust) and for Python/Node when you do not need a shell.
- `*-slim` or `*-alpine` official images (Debian slim / Alpine) when
a shell is genuinely required.
- `scratch` for statically-linked Go / Rust binaries.
- Full `ubuntu` / `debian` only when a specific system dependency
requires it; document why.
Never install build dependencies in the runtime stage. A `gcc`, `make`,
`build-essential`, `python3-dev`, or `npm install` in the final stage
is a rule violation.
Copy between stages with `COPY --from=builder /app/dist /app/`, not by
rebuilding.
Intermediate stages for tooling (linters, test runners) are fine —
they're not in the final image.
Use named stages (`FROM node:22-alpine AS builder`, not `FROM ... AS 0`).
Target specific stages in CI: `docker build --target test .` runs the
test stage; `docker build --target runtime .` ships the runtime stage.
BuildKit features (`RUN --mount=type=cache`, `RUN --mount=type=secret`)
are required. `# syntax=docker/dockerfile:1.7` pin at the top.
Before — single stage, dev deps shipped, 1.6 GB final image:
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD npm start
Ships node_modules with dev dependencies, src/ (already compiled), .git, tests, the whole repo. FROM node:latest pulls whatever "latest" was on build day.
After — multi-stage, distroless runtime, cache mount:
# syntax=docker/dockerfile:1.7
FROM node:22.12-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
FROM node:22.12-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12 AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
USER nonroot
EXPOSE 3000
CMD ["dist/server.js"]
Final image: ~180 MB (distroless + app + prod deps). Dev deps live in the builder. The npm cache mount survives across builds. No shell in production.
Rule 2: Pin Base Images to Specific Tags and Digests — Never :latest
FROM node:latest, FROM ubuntu:latest, FROM python:latest — these are three bugs waiting to happen. "Latest" is whatever the registry points it to on build day, which means your CI run on Tuesday and your CI run on Thursday may produce byte-for-byte different images with different CVE profiles. The fix is a specific minor version tag at minimum, a full digest (@sha256:...) for anything production-sensitive. Renovate / Dependabot can then propose upgrades as PRs you review, not as silent surprises.
The rule:
`FROM image:latest` is forbidden. Every FROM directive uses:
- A specific, minor-or-patch-level tag: `node:22.12-alpine3.20`,
`python:3.13.1-slim-bookworm`, `golang:1.23.4-alpine3.20`.
- Plus, for production / release Dockerfiles, a SHA-256 digest:
`FROM node:22.12-alpine3.20@sha256:abc123...`.
Tag alone: allowed for development Dockerfiles and for short-lived CI
images.
Tag + digest: required for any image that runs in production or is
published to a shared registry.
Alpine-based images pin BOTH the language version and the Alpine
version: `node:22.12-alpine3.20`, not `node:22-alpine` (which floats).
Renovate / Dependabot is configured to propose base-image updates as
PRs. Image updates merge through PR review like any code change.
Custom internal base images follow the same rules: `myorg/base:1.4.2`
with a digest when consumed in production.
`ARG VERSION` at the top is allowed for parameterized tags, but the
default value is a specific version, not a floating one.
When you pin a digest, a comment on the next line documents the
corresponding tag so the intent is readable:
FROM node@sha256:abc123...
# tag: 22.12-alpine3.20
Scratch, distroless, and scratch-derivative images still get pinned:
FROM gcr.io/distroless/static-debian12:nonroot@sha256:...
Version skew across stages is forbidden — builder and runtime MUST
agree on the language version (e.g., both node 22.12).
Before — floating tag, untracked, reproducibility impossible:
FROM node:latest
FROM nginx:alpine
After — pinned version + pinned Alpine + digest for production:
# syntax=docker/dockerfile:1.7
FROM node:22.12-alpine3.20@sha256:c17dd5b5d9c6e3e3f47a6bc9bd8c8e6f... AS builder
# tag: node:22.12-alpine3.20
FROM nginx:1.27.3-alpine3.20-slim@sha256:f2b4b1e91c3b9e3d... AS runtime
# tag: nginx:1.27.3-alpine3.20-slim
# renovate.json
{
"extends": ["config:base"],
"dockerfile": { "pinDigests": true }
}
Every build on every branch produces the same image bytes. Upgrades arrive as PRs with changelogs.
Rule 3: Layer Ordering and .dockerignore — Cache the Parts That Don't Change, Exclude Noise
The second-most-common AI Dockerfile failure is COPY . . at the top of the file, immediately followed by RUN npm install / pip install / bundle install. That busts the cache on every change to any file in the repo — a README edit re-downloads all npm packages. The discipline is: copy the manifest (package.json, requirements.txt, go.mod, Cargo.toml) first, install dependencies, then copy the source. And a .dockerignore that excludes node_modules, .git, *.log, secrets, and IDE detritus so the build context doesn't include a gigabyte of junk.
The rule:
Layer ordering, top to bottom, from "rarely changes" to "changes on
every commit":
1. FROM
2. ARG / ENV for build-time config
3. WORKDIR
4. System packages (apt / apk / dnf) — combined into ONE RUN with
lockfile cleanup: `RUN apt-get update && apt-get install -y
--no-install-recommends <pkgs> && rm -rf /var/lib/apt/lists/*`.
5. Language-level dependency manifests (COPY package.json
package-lock.json ./ / COPY requirements.txt / COPY go.mod go.sum).
6. Dependency install (npm ci / pip install / go mod download) —
cached as long as manifests don't change.
7. COPY application source.
8. Build step.
9. User, EXPOSE, HEALTHCHECK, ENTRYPOINT/CMD.
`.dockerignore` is mandatory. Must include at minimum:
node_modules
.git
.gitignore
.env*
!.env.example
*.log
**/__pycache__
.venv
.idea
.vscode
coverage
.next
dist
build
target
Dockerfile*
docker-compose*.yml
README*
.github
.DS_Store
Without .dockerignore, `COPY . .` ships your entire git history, secrets,
and dev artifacts into the build context (and into any leaked layer).
Combine related RUN commands with `&&` to avoid extra layers. One
`RUN apt-get install ... && rm -rf /var/lib/apt/lists/*`, not two
`RUN`s. But not so combined that cache invalidation is useless.
`COPY` over `ADD`. `ADD` is reserved for tar-auto-extract and remote
URLs — and even then, prefer an explicit `curl` + `tar` in a stage
that's cache-friendly.
Each `ENV` that depends on build-time inputs is set via `ARG` → `ENV`,
not hardcoded.
`chown` in `COPY --chown=nonroot:nonroot` rather than a subsequent
`RUN chown`.
Before — .dockerignore missing, source copied before install:
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
RUN apt-get update && apt-get install -y libpq-dev
CMD python app.py
Every README edit reinstalls every Python package and re-runs the apt update.
After — ordered layers, apt cleaned up, .dockerignore present:
# .dockerignore
.git
.gitignore
.env*
!.env.example
__pycache__
*.pyc
.venv
.pytest_cache
.mypy_cache
coverage.xml
.coverage
htmlcov
Dockerfile*
docker-compose*
.github
.vscode
README.md
docs/
tests/
# syntax=docker/dockerfile:1.7
FROM python:3.13.1-slim-bookworm AS runtime
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
COPY --chown=nonroot:nonroot src/ ./src/
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
USER nonroot
EXPOSE 8000
CMD ["python", "-m", "src.app"]
A source change rebuilds only the COPY src/ layer and anything below. Tests, CI logs, and .git never enter the build context.
Rule 4: Non-Root USER, Read-Only Root Filesystem, Drop Capabilities
The default container runs as root. Cursor will write a Dockerfile without a USER directive, without a read-only rootfs, without a CAP_DROP: ALL. That container, if compromised, gives the attacker a root shell on a Linux namespace with writable /, CAP_SYS_ADMIN-adjacent capabilities depending on runtime, and whatever volumes you mounted. The defense-in-depth rule is simple: unless your container genuinely needs root (rare), run as a non-root user, mount / read-only with specific writable volumes, and drop all Linux capabilities that the app doesn't demonstrably need.
The rule:
Every Dockerfile sets a non-root USER before CMD/ENTRYPOINT. Preferred
in order:
1. A USER built into the base image (distroless: `nonroot`; many
alpine bases: `node`, `nginx`, `postgres`).
2. An explicit `RUN adduser --system --uid 10001 --no-create-home
appuser` creating the user in the Dockerfile, then `USER appuser`.
3. `USER 10001:10001` numeric UID (required for Kubernetes
`runAsNonRoot: true`; a string USER is not portable).
The user's home directory is NOT under the app root. `WORKDIR /app`
with the user owning /app is fine; `/home/appuser` with the app living
there is a smell.
Runtime configuration (compose / Kubernetes / ECS):
- `read_only: true` on the container (Compose) or
`readOnlyRootFilesystem: true` (Kubernetes). App writes go to
explicitly-mounted tmpfs or volumes.
- `cap_drop: [ALL]` and `cap_add:` only the capabilities the app
genuinely needs (bind to <1024: NET_BIND_SERVICE; usually nothing).
- `security_opt: [no-new-privileges:true]`.
- Seccomp default profile (Docker: implicit; Kubernetes: explicit).
Privileged mode (`--privileged`, `privileged: true`) is forbidden
outside of Docker-in-Docker CI runners, and even then under scrutiny.
Docker socket mounting (`/var/run/docker.sock`) is forbidden in
application containers. Sidecar CI agents get a rootless alternative
(Sysbox, podman, kaniko) when they need image builds.
Secrets never appear in ENV at build time. Build secrets use
`RUN --mount=type=secret,id=NAME` (BuildKit). Runtime secrets come
from the orchestrator (Kubernetes Secrets, AWS Secrets Manager) or a
sidecar, never baked into a layer.
ENV vars for non-secrets only. `ENV DATABASE_URL=postgres://...` with
a real credential is a critical finding.
Before — runs as root, writable rootfs, secret baked in:
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
ENV DATABASE_URL=postgres://app:hunter2@db/app
CMD python app.py
Root user. Writable /. Real credential in the image layers forever.
After — non-root, read-only rootfs, runtime secrets:
# syntax=docker/dockerfile:1.7
FROM python:3.13.1-slim-bookworm AS runtime
RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home app
WORKDIR /app
COPY --chown=app:app requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
COPY --chown=app:app src/ ./src/
ENV PYTHONUNBUFFERED=1
USER 10001:10001
EXPOSE 8000
CMD ["python", "-m", "src.app"]
# docker-compose.yml (production-shape local)
services:
app:
build: .
read_only: true
tmpfs:
- /tmp:size=64m
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt:
- no-new-privileges:true
environment:
DATABASE_URL_FILE: /run/secrets/db_url
secrets:
- db_url
secrets:
db_url:
file: ./.secrets/db_url
Compromised process has no root, no write access to /, no elevated capabilities. The DB credential is read from a tmpfs-backed secret file, not from ENV.
Rule 5: HEALTHCHECK, STOPSIGNAL, and Graceful Shutdown
A container without a HEALTHCHECK is a black box to the orchestrator — it's running, but is it serving? AI assistants leave HEALTHCHECK out by default, or write one that curls a trivial endpoint that's always up regardless of dependency state. The second failure is a CMD npm start that runs npm as PID 1, which does not forward signals properly, so docker stop becomes a 10-second wait followed by a SIGKILL mid-request. The rule: exec-form ENTRYPOINT/CMD so the app is PID 1 and gets signals, a real HEALTHCHECK that probes liveness and readiness honestly, and a STOPSIGNAL that matches what the runtime expects.
The rule:
ENTRYPOINT / CMD are always in exec form (JSON array), not shell form:
CMD ["node", "dist/server.js"] # good, node is PID 1
CMD node dist/server.js # bad, /bin/sh -c wraps it,
# signals don't propagate
If a real shell is genuinely needed, use `tini` as the entrypoint:
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["your-shell-script.sh"]
HEALTHCHECK is declared in every Dockerfile:
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8000/healthz || exit 1
The health endpoint checks LIVENESS (the process can respond), not
external dependencies (if DB is down, restarting the container doesn't
help — that's a readiness concern handled by the orchestrator via a
separate /readyz endpoint).
Distroless / scratch images have no curl — use a statically-linked
health binary, a language-native health check, or Kubernetes probes
(preferred in Kubernetes: declare liveness/readiness in the Pod spec
rather than HEALTHCHECK).
STOPSIGNAL matches the runtime:
- Node.js: SIGTERM (default in Node 16+; app traps for graceful).
- nginx: SIGQUIT.
- PostgreSQL: SIGINT (smart shutdown) or SIGTERM (fast shutdown).
- Most others: SIGTERM.
The app handles SIGTERM:
- Stop accepting new connections.
- Finish in-flight requests (bounded by a drain timeout <
orchestrator's grace period, typically 30s).
- Close DB connections, drain queues.
- Exit 0.
`docker run --stop-timeout` / Kubernetes `terminationGracePeriodSeconds`
accommodates the drain.
Logs go to stdout / stderr. File logging from inside a container is a
smell — the orchestrator's log driver collects stdout.
PID 1 is never a shell script that forks — either the app is PID 1, or
tini / dumb-init is PID 1 reaping zombies.
Before — shell-form CMD, no healthcheck, signals ignored:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD npm run start
npm is PID 1. SIGTERM goes to npm, which doesn't forward it. docker stop waits 10s then SIGKILLs mid-request.
After — exec CMD, real healthcheck, graceful shutdown:
# syntax=docker/dockerfile:1.7
FROM node:22.12-alpine3.20 AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
COPY dist/ ./dist/
USER node
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
STOPSIGNAL SIGTERM
ENTRYPOINT ["node"]
CMD ["dist/server.js"]
// dist/server.js (graceful shutdown)
const server = app.listen(process.env.PORT);
const shutdown = async (signal) => {
console.log(JSON.stringify({ level: "info", msg: "shutdown", signal }));
server.close(async () => {
await db.end();
process.exit(0);
});
setTimeout(() => process.exit(1), 25_000).unref();
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
Node is PID 1. SIGTERM triggers server.close, in-flight requests finish, the process exits 0. The orchestrator sees the container cleanly terminate and moves on.
Rule 6: Docker Compose Is For Development (and CI), Not Production
AI assistants will cheerfully suggest docker-compose up as a production deployment strategy. It's not. Compose has no rolling updates, no scaling to multiple hosts, no secrets management beyond files on disk, no service mesh, no autoscaling, no image signing enforcement, no persistent storage beyond local volumes. The rule: Compose is for local dev, integration tests, and CI. Production runs on Kubernetes / ECS / Nomad / Swarm-mode-for-real-clusters. The Compose file should feel production-shaped (readonly, healthchecks, resource limits) but is not itself a production artifact.
The rule:
docker-compose.yml is DEV-ONLY.
Every service in compose has:
- `image:` OR `build:`, never both without explicit reason.
- `restart: unless-stopped` (dev) — matches what orchestrators do
in prod.
- `healthcheck:` matching the Dockerfile's (or overriding for dev).
- `depends_on:` with `condition: service_healthy`, never just a
name list (name list doesn't wait for readiness).
- `environment:` references to `.env` (git-ignored); never inline
secrets.
- `volumes:` for developer mounts (hot reload), explicitly marked
with a comment; production uses baked images, not mounts.
- Resource limits (`deploy.resources.limits.memory`, `cpus`) so dev
and CI hit the same limits as prod.
Production Dockerfile targets a stage named `runtime`; compose can
target `dev` or `test` for richer local experiences.
Secrets:
- Dev: `.env` + `.env.example` committed. `.env` in `.gitignore`.
`.env.example` has placeholder values and a comment per variable.
- Prod: orchestrator secrets.
Networks: explicit named networks, never implicit default. Services
that do not need to be reachable externally are on an internal network.
Ports: bind to `127.0.0.1:PORT:INTERNAL` on developer machines, not
`PORT:INTERNAL` (binds 0.0.0.0 — visible on LAN).
Named volumes (`volumes: [appdata:/var/lib/app]`) for stateful dev
services (Postgres, Redis). Bind mounts only for code-reload patterns.
The compose file is linted: `docker compose config` in CI to catch
typos before they hit the dev team.
For production-like local: `compose.production.yml` as an override
drops the code mounts, uses the built image, sets `read_only`, etc.
Before — inline secret, depends_on that doesn't wait, ports on 0.0.0.0:
version: "3"
services:
app:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://postgres:password@db/app
depends_on: [db]
db:
image: postgres
db starts accepting connections after 3 seconds, app starts in 500 ms and crashes. Secrets in the compose file. DB on the host LAN.
After — healthcheck-gated start, local-only ports, env via file:
name: myapp-dev
services:
app:
build:
context: .
target: runtime
restart: unless-stopped
ports: ["127.0.0.1:3000:3000"]
env_file: .env
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
interval: 15s
timeout: 3s
retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
db:
image: postgres:17.2-alpine3.20@sha256:...
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: app
secrets: [db_password]
volumes: [appdb:/var/lib/postgresql/data]
healthcheck:
test: ["CMD", "pg_isready", "-U", "app"]
interval: 10s
timeout: 3s
retries: 5
volumes:
appdb:
secrets:
db_password:
file: ./.secrets/db_password
.env.example is committed, .env isn't. Port is localhost-only. app waits for db to be ready, not just started. Dev limits match production limits.
Rule 7: Build Context Hygiene — .dockerignore, Reproducibility, No Secrets Ever Reach a Layer
Every file in the build context is a potential layer. AI assistants routinely write COPY . or COPY ./ /app/ in Dockerfiles without a .dockerignore, and without thinking about what's in the current directory. In CI the current directory contains the checked-out repo plus whatever the CI job wrote. If your CI caches node_modules/ on the runner, that becomes part of the image. If it writes an AWS session token to .aws/credentials, that's in the image too. The discipline: an aggressive .dockerignore, no secrets in build args, RUN --mount=type=secret for build-time secrets, and BuildKit's SBOM generation so you know exactly what shipped.
The rule:
Every repo with a Dockerfile has a .dockerignore at the same level.
Prefer an allowlist for tight control:
# deny all
*
# allow what we need
!package.json
!package-lock.json
!src/
!tsconfig.json
Or a thorough denylist (Rule 3 template).
Build-time secrets are NEVER passed via `--build-arg`. `--build-arg`
values are visible in the image's history (`docker history`).
Use `RUN --mount=type=secret,id=NAME` (BuildKit) and reference the
secret file inside the RUN:
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
Never `echo $SECRET >> /some/file` — the file persists in the layer.
Reproducible builds:
- `--build-arg SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)` for
deterministic timestamps.
- Pin base image digests (Rule 2).
- Pin language-level dependencies (lockfiles committed).
- Use `--output type=oci,dest=image.tar,name=...` for auditable
artifacts when needed.
Supply-chain:
- Image signing via cosign (sigstore) on every published image.
`cosign sign --yes $IMAGE` in the publish pipeline.
- SBOM generation with BuildKit: `docker buildx build --sbom=true
--provenance=true ...` (BuildKit 0.11+).
- Vulnerability scan (trivy, grype, snyk) in CI, fail the build on
CRITICAL findings by default.
- Base image updates come through Renovate/Dependabot, merge through
PR review, not ad-hoc rebuilds.
Image history must not contain:
- Tokens, keys, credentials.
- `.git` directory.
- CI runner identifiers.
- Developer home-directory leaks (.aws, .ssh).
Check with `docker history --no-trunc IMAGE` and `dive IMAGE` in
release engineering.
Registry rules:
- Image tags use SemVer (`v1.4.2`), commit SHAs (`sha-abc123`), or
both. `latest` for convenience links only, never for deploy targets.
- Registry pull secrets have per-repo scope, not global.
Before — secret via build-arg, full context copied, unscanned image:
ARG NPM_TOKEN
FROM node:22
WORKDIR /app
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
COPY . .
RUN npm install
CMD ["npm", "start"]
NPM_TOKEN is in the image history. ~/.npmrc persists in the layer. Full repo (including .git) copied.
After — mount secret, SBOM+scan in CI, signed publish:
# syntax=docker/dockerfile:1.7
FROM node:22.12-alpine3.20@sha256:... AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npm_token \
--mount=type=cache,target=/root/.npm \
NPM_TOKEN=$(cat /run/secrets/npm_token) \
npm ci --ignore-scripts
COPY . .
RUN npm run build
# .github/workflows/build.yml
- name: Build & push
run: |
docker buildx build \
--tag ghcr.io/org/app:${{ github.sha }} \
--platform linux/amd64,linux/arm64 \
--secret id=npm_token,env=NPM_TOKEN \
--sbom=true --provenance=true \
--push .
- name: Scan
uses: aquasecurity/trivy-action@0.24.0
with:
image-ref: ghcr.io/org/app:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: 1
- name: Sign
run: cosign sign --yes ghcr.io/org/app:${{ github.sha }}
Token never enters the image. SBOM attaches to the image in the registry. CRITICAL CVEs fail the build. Every published image is signed.
Rule 8: Resource Limits, Logging Driver, and Runtime Observability
A container without memory and cpu limits will happily exhaust the host. A container that logs to a file inside its rootfs will fill the disk. AI assistants omit both. The rule: every container, everywhere it runs, has resource limits that are meaningful (dev ≈ prod), logs to stdout/stderr only, and exposes metrics + tracing on conventional endpoints (/metrics for Prometheus, OTLP for traces).
The rule:
Every container run declares limits:
Compose: `deploy.resources.limits.{memory,cpus}` and
`deploy.resources.reservations.{memory,cpus}`.
Kubernetes: `resources.limits` + `resources.requests`.
ECS: `memory` + `cpu` at the task/container level.
Limits are set based on measured RSS and CPU time, not guessed.
`requests == limits` for latency-sensitive apps (no CPU throttling),
`requests < limits` for burstable batch work.
Memory limit never defaults to the host memory. 128 MB is the floor
for anything nontrivial; pick the number deliberately.
Logging:
- Logs go to stdout (info) and stderr (errors).
- Format is structured JSON in prod (for log shippers), pretty in dev.
- Log rotation is the logging driver's job, not the app's. Set
`logging.options.max-size` / `max-file` on the Docker daemon or
per-container.
- No file logging from inside the container. No log mounts.
Observability:
- `/healthz` — liveness: returns 200 if the process can answer.
- `/readyz` — readiness: returns 200 if the process is ready to
serve traffic (DB reachable, caches warm).
- `/metrics` — Prometheus: metrics in exposition format.
- OpenTelemetry SDK wired with OTLP exporter pointed at an
`OTEL_EXPORTER_OTLP_ENDPOINT` env variable.
Default logging driver (json-file) is configured with rotation in the
daemon config:
{ "log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" } }
Production runtimes configure a log shipping driver (fluentd, awslogs,
gcplogs, splunk) or rely on node-level DaemonSets.
Metrics endpoints are bind-only to the internal network, never exposed
to the public internet through the image.
Build-time versioning: LABEL org.opencontainers.image.* fields populated
LABEL org.opencontainers.image.source="https://github.com/org/repo"
LABEL org.opencontainers.image.revision="$GIT_SHA"
LABEL org.opencontainers.image.created="$BUILD_DATE"
LABEL org.opencontainers.image.licenses="MIT"
A running container answers `docker inspect` with enough labels to
trace back to the commit, the build, and the CI run.
Before — no limits, log file inside the container, no observability:
services:
app:
build: .
logging:
driver: json-file
CMD ["sh", "-c", "node dist/server.js >> /var/log/app.log 2>&1"]
No limits — OOMs crash the host. Logs go to a file that grows until the rootfs fills.
After — limits, stdout logging, metrics, OTel:
services:
app:
image: ghcr.io/org/app@sha256:...
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
OTEL_SERVICE_NAME: app
deploy:
resources:
limits: { memory: 512M, cpus: "1.0" }
reservations: { memory: 256M, cpus: "0.5" }
LABEL org.opencontainers.image.source="https://github.com/org/app" \
org.opencontainers.image.revision="${GIT_SHA}" \
org.opencontainers.image.created="${BUILD_DATE}"
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "dist/server.js"]
Limits prevent runaway memory from paging the host. Logs ship via the driver, not a container-local file. /metrics and /healthz are scraped by Prometheus / the orchestrator. The running container traces back to its commit.
The Complete .cursorrules File
Drop this in the repo root. Cursor and Claude Code both pick it up.
# Docker — Production Patterns
## Multi-stage builds
- Every production Dockerfile is multi-stage.
- Builder stage has compilers/dev deps; runtime stage is minimal.
- Runtime base preference: distroless > slim/alpine > scratch > full.
- No build deps in the runtime stage.
- COPY --from=builder for artifacts; named stages (AS name).
- BuildKit syntax pin: `# syntax=docker/dockerfile:1.7`.
- Target-specific builds: `docker build --target runtime .`.
## Base image pinning
- FROM image:latest is forbidden.
- Use specific tags: `node:22.12-alpine3.20`, `python:3.13.1-slim-bookworm`.
- Production Dockerfiles add digest: `image:tag@sha256:...`.
- Pin BOTH language version AND base distro version.
- Builder and runtime agree on language version.
- Renovate/Dependabot proposes updates; merges through PR.
- Tag-with-digest followed by `# tag: foo` comment for readability.
## Layer ordering & context
- Order: FROM → ARG/ENV → WORKDIR → system packages → manifests
→ dep install → source → build → USER/EXPOSE/HEALTHCHECK/CMD.
- COPY package.json/requirements.txt BEFORE COPY . — keep dep cache.
- Single RUN for apt install with --no-install-recommends and
`rm -rf /var/lib/apt/lists/*`.
- COPY over ADD (ADD only for tar-auto-extract or remote).
- .dockerignore is mandatory: exclude node_modules, .git, .env*,
*.log, __pycache__, .venv, IDE dirs, Dockerfile*, docker-compose*.
- COPY --chown=user:group, not subsequent RUN chown.
## Security
- USER is non-root before CMD. Numeric UID for Kubernetes compatibility.
- Runtime: read_only: true + tmpfs for writes; cap_drop: [ALL];
security_opt: [no-new-privileges:true].
- Privileged mode forbidden outside CI builders.
- No /var/run/docker.sock in app containers.
- Build-time secrets via RUN --mount=type=secret (BuildKit), never
--build-arg.
- No secret ENV values in Dockerfile; runtime secrets from orchestrator.
## Runtime
- ENTRYPOINT/CMD in exec form (JSON array); shell form breaks signals.
- tini/dumb-init as PID 1 when a shell wrapper is truly needed.
- HEALTHCHECK on every Dockerfile; /healthz for liveness,
/readyz for readiness.
- STOPSIGNAL matches the runtime (nginx SIGQUIT, most others SIGTERM).
- App traps SIGTERM and drains < orchestrator grace period.
- Logs to stdout/stderr only; structured JSON in prod.
## Compose (dev only)
- docker-compose.yml is dev/CI only — never production.
- restart: unless-stopped; healthcheck matches Dockerfile.
- depends_on uses `condition: service_healthy`, not bare names.
- env_file: .env (gitignored); .env.example committed with comments.
- Ports bind 127.0.0.1:PORT:INT on dev, not 0.0.0.0.
- Named volumes for stateful services; bind mounts only for code-reload.
- Resource limits in deploy.resources.limits — match prod.
- `docker compose config` linted in CI.
## Supply chain
- .dockerignore allowlist preferred for tight control.
- BuildKit --sbom=true --provenance=true when publishing.
- Image signing with cosign on every published tag.
- Trivy/grype scan in CI; fail on CRITICAL.
- OCI labels populated: source, revision, created, licenses.
- Image tags: SemVer or sha-<commit>; `latest` only as convenience link.
- Registry pull secrets scoped per-repo, not global.
## Runtime observability
- Resource limits everywhere: memory + cpu (requests + limits).
- Logging driver rotation: max-size + max-file.
- /healthz, /readyz, /metrics (Prometheus), OTLP traces.
- Metrics endpoints on internal network only.
- No file logging inside container; no log mounts.
- LABEL org.opencontainers.image.* populated at build time.
End-to-End Example: A Production Dockerfile With Compose Override
Without rules: 1.6 GB single-stage, root, no healthcheck, secrets in compose.
FROM node:latest
WORKDIR /app
COPY . .
RUN npm install
CMD npm start
With rules: 180 MB distroless, non-root, healthcheck, signed, scanned.
# syntax=docker/dockerfile:1.7
FROM node:22.12-alpine3.20@sha256:abc... AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev --ignore-scripts
FROM node:22.12-alpine3.20@sha256:abc... AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12:nonroot@sha256:def... AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
USER nonroot
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/nodejs/bin/node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1))"]
STOPSIGNAL SIGTERM
LABEL org.opencontainers.image.source="https://github.com/org/app" \
org.opencontainers.image.revision="${GIT_SHA}"
CMD ["dist/server.js"]
# docker-compose.yml (dev)
name: app-dev
services:
app:
build: { context: ., target: runtime }
restart: unless-stopped
ports: ["127.0.0.1:3000:3000"]
env_file: .env
read_only: true
tmpfs: [/tmp:size=64m]
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/healthz"]
interval: 15s
timeout: 3s
retries: 5
deploy:
resources:
limits: { memory: 512M, cpus: "1.0" }
db:
image: postgres:17.2-alpine3.20@sha256:...
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: app
secrets: [db_password]
volumes: [appdb:/var/lib/postgresql/data]
healthcheck:
test: ["CMD", "pg_isready", "-U", "app"]
interval: 10s
timeout: 3s
retries: 5
volumes: { appdb: {} }
secrets:
db_password: { file: ./.secrets/db_password }
Reproducible across builds. Non-root in production and dev. Healthchecks gate startup and orchestrator restart. CVEs surface as PRs, not production incidents.
Get the Full Pack
These eight rules cover the Docker patterns where AI assistants consistently reach for the wrong idiom. Drop them into .cursorrules and the next prompt you write will look different — multi-stage, pinned, non-root, healthcheck-gated, signal-safe, Compose-for-dev, scanned, signed, observable Docker, without having to re-prompt.
If you want the expanded pack — these eight plus rules for Docker buildx bake files, cross-platform (amd64 + arm64) CI, Kubernetes Pod specs that mirror these Dockerfile rules, Helm chart conventions, registry mirror configuration, rootless Docker and Podman, dive and image-size regression gates in CI, and the deploy patterns I use for container workloads on AWS ECS and EKS — it is bundled in Cursor Rules Pack v2 ($27, one payment, lifetime updates). Drop it in your repo, stop fighting your AI, ship Docker you would actually merge.
Top comments (0)