Docker & Container Patterns
PLDocker 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.
What a verified container setup gives you
Section titled “What a verified container setup gives you”- 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.
Give the agent your container rules first
Section titled “Give the agent your container rules first”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.
Add the block to AGENTS.md at the repository root. Codex reads AGENTS.md before it does any work. Since Codex 0.150.0, an untrusted project does not supply its project-level AGENTS.md, so mark the repository as trusted when Codex asks.
Save the block as a project rule (see project rules in Cursor) so Agent applies it to every conversation in the repository. Keep the text identical to the other tools’ files so that all three agents follow one rule set.
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 pipefailIMAGE="$1"; MAX_MB="${2:-150}" # SKIP_BUILD=1 checks an image that is already built
docker run --rm -i hadolint/hadolint < Dockerfile # 1. Dockerfile lintdocker 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 runtrap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXITfor _ in $(seq 1 30); do STATUS=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID") [ "$STATUS" = healthy ] && break; sleep 2done[ "$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:
| Gate | Goes red when | What you stop checking by hand |
|---|---|---|
hadolint and docker build --check | Unpinned tags, apt-get without cleanup, JSON-form CMD mistakes | Dockerfile style |
| Size budget | A build toolchain or dev dependency leaked into the final stage | Stage boundaries |
| Non-root user | The final stage has no USER | Privilege model |
Trivy with --exit-code 1 | A fixable critical or high CVE in any layer | Base-image hygiene |
| Hardened smoke run | The app writes outside /tmp, needs a capability or never turns healthy | Runtime assumptions |
| Stop time | PID 1 ignores SIGTERM and Docker kills it after the timeout | Signal 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:1FROM node:24-alpine AS builderWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm npm ciCOPY . .RUN npm run build
FROM node:24-alpine AS runtimeRUN apk add --no-cache tiniWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm npm ci --omit=devCOPY --from=builder /app/dist ./distUSER 10001EXPOSE 3000HEALTHCHECK --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):
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 jsonDocker 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.
Run it interactively with codex, or unattended with a workspace-write sandbox:
codex exec --sandbox workspace-write "$(cat prompts/multi-stage.txt)"The sandbox can block the Docker socket and the network that docker build needs. If the build fails inside Codex, let Codex edit the files, then run scripts/verify-image.sh yourself and paste the output back. Do not switch to danger-full-access or --dangerously-bypass-approvals-and-sandbox outside a disposable, network-restricted VM.
Paste the prompt into Agent with the Dockerfile and package.json in context. Agent runs docker commands in the integrated terminal, subject to the terminal approval settings you have chosen. Review the diff view for the .dockerignore change, then check that the terminal shows the PASS line from the script.
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:1FROM golang:1.27-alpine AS builderWORKDIR /srcCOPY go.mod go.sum ./RUN --mount=type=cache,target=/go/pkg/mod go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian13:nonrootCOPY --from=builder /out/server /serverEXPOSE 8080HEALTHCHECK --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.
Gate Compose startup on health, not sleep
Section titled “Gate Compose startup on health, not sleep”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.
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.
Build, scan and push images in CI
Section titled “Build, scan and push images in CI”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.
name: imageon: 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=ghaThree 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
pushjob never starts if Trivy or the script fails. The scan covers the amd64 build only; thepushjob 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:
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.jsonThe 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.
codex exec --sandbox workspace-write -o triage.md "$(cat prompts/triage-scan.txt)"-o writes the agent’s final message to a file you can attach to the pull request. If the sandbox blocks Docker, run the Trivy command yourself, save its output as trivy.json and pass that file to Codex.
Paste the prompt into Agent. If the scan already ran in CI, download the SARIF artifact, add it to the chat instead of the first line of the prompt, and let Agent map findings to lines.
What breaks when an agent writes your container setup?
Section titled “What breaks when an agent writes your container setup?”Where to go next with container work
Section titled “Where to go next with container work”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.
What will you do with it?
How did it read?
What got in the way? Pick any that apply
Your role?
Thanks. I read every answer. Michał Jaskólski
Did this guide help?
One tap each. Close whenever you like.
no sign-in