Skip to content

Docker patterns for AI coding agents are a set of prompts and gates for letting Claude Code, Codex or Cursor write Dockerfiles, Compose files and image pipelines. The agent drafts multi-stage builds, health-gated Compose stacks and scanning CI; one verification script proves each image’s size, user, vulnerabilities, health and shutdown, so a reviewer checks results instead of every line.

This page is for developers who own a service’s container and tech leads who want the same container rules across a team. Your image is 1.2 GB, CI rebuilds every dependency on each push, the scanner lists 40 CVEs in the base layer, and the container takes 10 seconds to stop because PID 1 ignores SIGTERM. An agent can fix all of that in one session. The hard part is knowing the fix is real.

  • A container rules file the agent reads before it touches a Dockerfile, in the format each tool loads.
  • scripts/verify-image.sh: one script that fails on lint errors, an oversized image, a root user, a critical CVE, a missing health status or a slow shutdown.
  • Three copy-paste prompts: a multi-stage rewrite, a size reduction that names what each cut removes, and health-gated Compose.
  • A GitHub Actions workflow that scans before it pushes, with least-privilege permissions.
  • The six ways agent-written container work breaks, and how to recover from each.

An agent that has never seen your rules writes the Dockerfile an average tutorial would: :latest tags, a root user and no health check. Write the rules once and put them where each tool loads them automatically.

## Container rules
- Pin base images to an explicit version tag (node:24-alpine, never node:latest). Digests are pinned by Renovate or Dependabot, not by hand.
- Multi-stage builds only. Compilers, dev dependencies and caches never reach the final stage.
- The final stage runs as a non-root numeric UID and declares a HEALTHCHECK that uses the app's own runtime, not curl.
- Copy dependency manifests and install before `COPY . .`, so a source change does not invalidate the dependency layer.
- Ship a .dockerignore that excludes .git, node_modules, .env* and test fixtures.
- Secrets never go in ENV, ARG or a layer. Use `RUN --mount=type=secret` at build time and runtime secrets from the orchestrator.
- Never edit .trivyignore or lower a scanner severity threshold to make a check pass. Report the finding instead.
- After any container change, run scripts/verify-image.sh and paste its full output in your summary.

Add the block to CLAUDE.md at the repository root. Claude Code loads it at the start of every session, including headless claude -p runs.

The last two rules matter most. The first stops the agent from “fixing” a scan by hiding it. The second makes the agent produce evidence you can read in 30 seconds.

Prove an image without reading the Dockerfile

Section titled “Prove an image without reading the Dockerfile”

The verification script is the contract. The agent may change any line of the Dockerfile; the change is done only when this script passes. Ask the agent to write it once, then keep it in the repository and run it locally and in CI.

#!/usr/bin/env bash
# scripts/verify-image.sh IMAGE [MAX_MB] (runs in your terminal or in CI)
set -euo pipefail
IMAGE="$1"; MAX_MB="${2:-150}" # SKIP_BUILD=1 checks an image that is already built
docker run --rm -i hadolint/hadolint < Dockerfile # 1. Dockerfile lint
docker build --check . # 2. BuildKit build checks
[ "${SKIP_BUILD:-0}" = 1 ] || docker build -t "$IMAGE" . # 3. build
SIZE_MB=$(( $(docker image inspect -f '{{.Size}}' "$IMAGE") / 1000000 ))
[ "$SIZE_MB" -le "$MAX_MB" ] || { echo "FAIL size ${SIZE_MB}MB > ${MAX_MB}MB"; exit 1; }
USER_ID=$(docker image inspect -f '{{.Config.User}}' "$IMAGE")
case "$USER_ID" in ""|root|0|0:*) echo "FAIL image runs as root"; exit 1;; esac
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy \
image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed "$IMAGE" # 4. CVE gate
CID=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL \
--security-opt no-new-privileges "$IMAGE") # 5. hardened smoke run
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT
for _ in $(seq 1 30); do
STATUS=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")
[ "$STATUS" = healthy ] && break; sleep 2
done
[ "$STATUS" = healthy ] || { docker logs "$CID"; echo "FAIL health: $STATUS"; exit 1; }
START=$(date +%s); docker stop -t 10 "$CID" >/dev/null # 6. graceful shutdown
[ $(( $(date +%s) - START )) -lt 5 ] || { echo "FAIL slow stop: SIGTERM ignored?"; exit 1; }
echo "PASS $IMAGE ${SIZE_MB}MB user=$USER_ID"

What each gate proves, so you know what you no longer have to read:

GateGoes red whenWhat you stop checking by hand
hadolint and docker build --checkUnpinned tags, apt-get without cleanup, JSON-form CMD mistakesDockerfile style
Size budgetA build toolchain or dev dependency leaked into the final stageStage boundaries
Non-root userThe final stage has no USERPrivilege model
Trivy with --exit-code 1A fixable critical or high CVE in any layerBase-image hygiene
Hardened smoke runThe app writes outside /tmp, needs a capability or never turns healthyRuntime assumptions
Stop timePID 1 ignores SIGTERM and Docker kills it after the timeoutSignal handling

Who signs off: the developer who asked for the change reads the script output and the diff of .dockerignore, .trivyignore and the CI workflow. Those three files change what the gates see, so a human owns them. Put them under a CODEOWNERS entry for the platform owner. For the whole pull request, the evidence bundle describes what else the agent attaches.

Rewrite a Dockerfile as a multi-stage build

Section titled “Rewrite a Dockerfile as a multi-stage build”

The pattern that pays most is a builder stage with the full toolchain and a runtime stage that receives only the build output and production dependencies. The common agent mistake is to copy the builder’s node_modules, dev dependencies included, into the runtime stage.

A correct result looks like this. Use it to spot-check the agent’s output, not to review every line:

# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM node:24-alpine AS runtime
RUN apk add --no-cache tini
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER 10001
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD ["node", "dist/healthcheck.js"]
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/index.js"]

How you run the prompt differs by tool, mostly in whether the agent can reach the Docker daemon:

Run it interactively from the repository root and approve the docker commands when asked. For an unattended run, allow only the tools the task needs. claude -p starts in Manual permission mode (auto mode on third-party providers or with telemetry off, from v2.1.285, where a classifier decides), so add --permission-mode dontAsk to refuse anything that would prompt on every provider (file reads in the working directories and read-only Bash commands still run):

Terminal window
claude -p "$(cat prompts/multi-stage.txt)" \
--permission-mode dontAsk \
--allowedTools "Read,Edit,Write,Bash(docker build *),Bash(docker image *),Bash(scripts/verify-image.sh *)" \
--output-format json

Docker socket access is root-equivalent on the host: the verification script mounts /var/run/docker.sock, so run this only in a disposable VM or CI runner, see agent sandboxes compared. With sandboxed Bash turned on, the sandbox can block the Docker socket. If docker build fails with a permission error on /var/run/docker.sock, run scripts/verify-image.sh yourself and paste the output back into the session.

Shrink an image without breaking it at runtime

Section titled “Shrink an image without breaking it at runtime”

Once the image builds, make it small. Distroless and scratch runtimes remove the shell and package manager, and a static Go binary can run on almost nothing. The risk is an image that is smaller because it lost something the app needs at runtime: CA certificates, time zone data or a C library. Make the agent state the risk of each cut.

For a Go service, a correct final stage looks like this. It copies nothing but the binary, because the distroless/static image already ships CA certificates and time zone data:

# syntax=docker/dockerfile:1
FROM golang:1.27-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=builder /out/server /server
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["/server", "healthcheck"]
ENTRYPOINT ["/server"]

A distroless image has no shell, so a HEALTHCHECK that calls curl or sh cannot work. The exec-form check above runs the binary itself, so the binary must implement a healthcheck subcommand that calls its own /health endpoint and exits 0 or 1. Without that subcommand, the container never turns healthy and scripts/verify-image.sh fails at the health gate, which is the point: the rules file requires a working health check.

Bare depends_on waits only until a dependency’s container starts, not until it accepts connections. Agents also still write the obsolete top-level version: key, which current Docker Compose ignores with a warning. Ask for both explicitly.

compose.yaml
services:
api:
build: .
ports: ["8080:8080"]
environment:
DATABASE_URL: postgresql://app:${DB_PASSWORD:?set DB_PASSWORD}@postgres:5432/app
REDIS_URL: redis://redis:6379
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
read_only: true
tmpfs: [/tmp]
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
pgdata:

docker compose up -d --wait is the verification: it returns non-zero if any service with a health check does not become healthy. The read_only, tmpfs, cap_drop and security_opt keys on api are the Compose equivalent of the hardened smoke run in the script. They live in the runtime configuration, not in the image, so an agent that only edits the Dockerfile never adds them.

The first job, image, builds the image once, scans it with a SARIF report, then runs the local script against that same image with SKIP_BUILD=1. A second job, push, depends on it and runs only for pushes to main: it is the only job that holds packages: write, so a pull request never gets a token that can write to the registry. Every gate must pass before anything is pushed.

.github/workflows/image.yml
name: image
on:
pull_request:
push:
branches: [main]
permissions: {}
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload the SARIF report
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Build for scanning (amd64, not pushed)
uses: docker/build-push-action@v7
with:
context: .
load: true
tags: ${{ env.IMAGE }}:scan
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan and fail on fixable critical or high CVEs
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: ${{ env.IMAGE }}:scan
severity: CRITICAL,HIGH
ignore-unfixed: true
exit-code: "1"
format: sarif
output: trivy.sarif
- name: Upload scan results
if: always()
continue-on-error: true # fork PRs get a read-only token
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy.sarif
- name: Run every local gate against the scanned image
run: SKIP_BUILD=1 scripts/verify-image.sh "${{ env.IMAGE }}:scan" 150
push:
needs: image # runs only if every gate in the image job passed
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GHCR; never granted on pull requests
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push multi-arch image with SBOM and provenance
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
sbom: true
provenance: mode=max
tags: ${{ env.IMAGE }}:sha-${{ github.sha }}
cache-from: type=gha

Three choices in this workflow are deliberate:

  • Scan before push. A scan after push leaves a vulnerable image in the registry that anything can pull. Here the push job never starts if Trivy or the script fails. The scan covers the amd64 build only; the push job rebuilds both architectures from the same cache, so the pushed image is not byte-identical to the scanned one. If you need that proof, also scan the pushed digest, or scan each architecture in a matrix job.
  • Pin actions to a full commit SHA before you adopt the workflow. The tags above were the latest major versions on 2026-09-26. Tags can be moved; a SHA cannot. Let Dependabot or Renovate update the SHAs.
  • Deployment is a separate job. Rollout, probes and rollback belong to the orchestrator. See Kubernetes patterns and CI/CD pipeline patterns.

When the scan fails, an agent can triage and fix it without registry credentials. Give it the report, not the repository’s secrets:

Terminal window
claude -p "$(cat prompts/triage-scan.txt)" \
--allowedTools "Read,Edit,Bash(docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy *),Bash(scripts/verify-image.sh *)" \
--output-format json > triage.json

The Trivy command mounts the Docker socket, which is root-equivalent on the host, so run this only in a disposable VM or CI runner. The prompt reads Trivy’s report from standard output rather than a shell redirect, so the one Bash rule covers the scan and the run needs no Write permission for it. To run the same triage inside GitHub Actions, see headless agents in CI. Run it only on trusted refs, never on a pull_request_target checkout of a contributor’s branch.

What breaks when an agent writes your container setup?

Section titled “What breaks when an agent writes your container setup?”

This page is one recipe in DevOps and infrastructure recipes, which collects the related Kubernetes, CI/CD and infrastructure-as-code recipes.

Did this guide help you?

One tap, no sign-in.

not at all a lot