Docker and Kubernetes setup with Claude Code
PLClaude Code generates Dockerfiles, Docker Compose stacks, Kubernetes manifests, and Helm charts from the repository it runs in: the lockfile, the build scripts, the health endpoint, and the ports the code opens. The output earns trust only through mechanical gates: hadolint, a Trivy scan, kubeconform schema validation, and a smoke deploy to a throwaway kind cluster.
Your service works on your laptop, and now it has to run in a cluster. The Dockerfile a tutorial gives you often builds a gigabyte-plus image that runs as root, and the first manifest often has no resource limits, no readiness probe, and a database password in an environment variable. You want Claude Code to write all of it, but you cannot read 600 lines of YAML on every change.
This page is for the developer who containerizes a service and the tech lead who owns the team’s container standards. It covers writing and proving the container artifacts with Claude Code. Shipping them is on deployment automation with Claude Code, the pipeline that builds them is on pipeline configuration with Claude Code, and the cross-tool version of this workflow (Cursor and Codex included) is Docker and Kubernetes containerization. To run Claude Code itself inside a container, see development container setup.
What you’ll walk away with from containerizing with Claude Code
Section titled “What you’ll walk away with from containerizing with Claude Code”- A
CLAUDE.mdsection that encodes your container standards once, so every Dockerfile and manifest Claude writes follows them. - Permission rules and a
PostToolUsehook that lint every container file Claude edits and keep it away from shared clusters and registries. - Copy-paste prompts for a multi-stage Dockerfile, a development Compose stack, hardened Kubernetes manifests, and a Helm chart.
- A five-gate verification sequence that proves the artifacts work without a line-by-line read, and names who signs off.
- The ways agent-written container config fails, and how to recover from each.
Set the container rules before the first prompt
Section titled “Set the container rules before the first prompt”Claude’s defaults are plausible, not yours: a floating node:latest tag, a root user, and probes on paths your service does not serve. Write the rules into the project’s CLAUDE.md once. Every later prompt inherits them, and the last line turns “Claude wrote a Dockerfile” into “Claude wrote a Dockerfile and ran the checkers on it”.
## Containers- Base images: pin by tag AND digest (`node:22-bookworm-slim@sha256:...`). Resolve digests with `docker buildx imagetools inspect IMAGE:TAG`; never write a digest from memory.- Every Dockerfile is multi-stage. The runtime stage runs as UID 10001, has no build tools, and declares a HEALTHCHECK that calls GET /healthz.- The service reads config from env vars only. Secrets come from Kubernetes Secrets (created by External Secrets), never from values.yaml, ConfigMaps or ENV lines.- Manifests live in deploy/k8s/, the Helm chart in deploy/chart/.- Every container sets resources.requests and limits, runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, and drops ALL capabilities.- Probes: /healthz = process alive (liveness), /readyz = dependencies reachable (readiness).- You may use the kube context `kind-dev` (kind cluster `dev`) only. Never push images or touch any other context.- After editing container files, run: hadolint, trivy config, kubeconform (see scripts/check-containers.sh).Keep Claude away from shared clusters and registries
Section titled “Keep Claude away from shared clusters and registries”Container work sits one command away from production: docker push, helm upgrade, and kubectl delete all work from the same terminal. Two layers keep a session contained.
The boundary is the credential. Start Claude Code with a kubeconfig that holds only the local cluster, so there is no production context to reach:
# Terminal: create a local cluster and a kubeconfig that contains nothing elsekind create cluster --name dev --kubeconfig "$HOME/.kube/kind-dev.yaml"KUBECONFIG="$HOME/.kube/kind-dev.yaml" claudeThe deny list catches the common spellings. Add this to .claude/settings.json and commit it:
{ "permissions": { "deny": [ "Bash(docker push *)", "Bash(helm upgrade *)", "Bash(helm uninstall *)", "Bash(kubectl delete *)" ], "ask": [ "Bash(kubectl apply *)", "Bash(helm install *)" ] }}Lint every container file Claude edits
Section titled “Lint every container file Claude edits”A PostToolUse hook runs after each edit and, with exit code 2, puts the linter output in front of Claude, which fixes it on the next step. Save the script as .claude/hooks/lint-containers.sh, run chmod +x on it, and register it in the same .claude/settings.json. The full event and handler reference is on Claude Code hooks.
#!/usr/bin/env bash# .claude/hooks/lint-containers.sh (PostToolUse, matcher "Edit|Write"); needs jq, hadolint, kubeconformset -uo pipefailfile=$(jq -r '.tool_input.file_path // empty')[[ -f $file ]] || exit 0
case "$file" in *Dockerfile*) out=$(hadolint "$file" 2>&1) || { echo "hadolint failed on $file:" >&2; head -n 30 <<<"$out" >&2; exit 2; } ;; */deploy/k8s/*.yaml|*/deploy/k8s/*.yml) out=$(kubeconform -strict -summary -ignore-missing-schemas "$file" 2>&1) || { echo "kubeconform failed on $file:" >&2; head -n 30 <<<"$out" >&2; exit 2; } ;;esacexit 0{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-containers.sh" } ] } ] }}The edit has already happened when a PostToolUse hook runs, so the hook cannot block it; exit code 2 hands the linter output back to Claude as feedback. Test the hook once by asking Claude to add FROM node:latest to a scratch Dockerfile: the next reply should quote hadolint’s DL3007 warning and pin the tag.
Generate a production Dockerfile from the repository
Section titled “Generate a production Dockerfile from the repository”Ask for the Dockerfile in terms of your repository’s facts. Claude reads the lockfile to pick the package manager, the build script to find the output directory, and the server code to find the port and the health route. Ask it to finish with a table: that table is what you review, not the Dockerfile.
For a Go service, expect a static binary copied into a scratch or distroless stage. For Python, expect a virtual environment built in one stage and copied into the runtime stage. If the table shows a large image, iterate with the number, not a vague request:
The runtime image is 480 MB. List the five largest layers with `docker history myapp:test`,explain what each contains, and remove everything the runtime stage does not execute.Build a Compose stack for local development
Section titled “Build a Compose stack for local development”Compose is where developers run the service with its dependencies. The one line that matters most is depends_on with condition: service_healthy. Without it, the app starts before PostgreSQL accepts connections and crashes on boot.
docker compose up --wait blocks until every service with a health check reports healthy, so the prompt ends with evidence rather than a claim.
Generate hardened Kubernetes manifests
Section titled “Generate hardened Kubernetes manifests”Kubernetes YAML is where omissions hide: a missing readinessProbe still applies cleanly and then serves traffic before the app is ready. Put every requirement in the prompt and ask for a summary table you can check against your platform standards.
The CPU limit is omitted on purpose: a CPU limit throttles a pod even when the node has idle cores, which shows up as latency spikes rather than errors. Keep the memory limit, because memory cannot be throttled, only killed.
Turn the manifests into a Helm chart
Section titled “Turn the manifests into a Helm chart”Once the raw manifests pass on kind, a chart parameterizes them per environment. Convert working manifests rather than generating a chart from nothing: the diff between the rendered chart and the proven manifests is then your review surface.
Verify the container artifacts without reading every line
Section titled “Verify the container artifacts without reading every line”Every gate below runs mechanically, in the session and again in CI. Together they turn “the YAML looks right” into evidence.
-
Lint the Dockerfile. hadolint catches unpinned base images,
apt-getwithout cleanup, and shell-formCMDthat swallows signals. The hook above runs it on every edit. -
Scan the image and the config. Trivy runs twice:
trivy image --severity HIGH,CRITICAL --exit-code 1 myapp:testfor vulnerable packages, andtrivy config deploy/for misconfigurations such as a root user or a missingreadOnlyRootFilesystem.docker scout cve myapp:testis an alternative for the image scan if your team already uses Docker Scout. -
Validate every manifest against the Kubernetes schema. kubeconform with
-strictrejects unknown fields, which is how a misspelledreadinessprobegets caught instead of being silently ignored. Custom resources such asExternalSecrethave no built-in schema, so-ignore-missing-schemasskips them; point-schema-locationat a CRD schema catalog if you want them checked too. Pipehelm templateoutput into it for the chart. -
Smoke-deploy to a throwaway cluster. A fresh kind cluster lacks what production provides, so prepare it first or the rollout never finishes:
- Load the image. A kind node cannot pull an image that exists only in your local Docker:
kind load docker-image myapp:test --name dev. - Switch off External Secrets. The cluster has no External Secrets CRDs, so
values-kind.yamlsetsexternalSecret.enabled: false,image: myapp:test, andpullPolicy: IfNotPresent. - Create a stand-in Secret.
kubectl create secret generic myapp-db --from-literal=DATABASE_URL='postgres://app:app@postgres:5432/app'points at the throwaway Postgres below. - Apply the dependencies.
/readyzchecks the database and Redis, so apply the throwaway Postgres and Redis fromdeploy/kind/in the same namespace.
Then install the chart, wait for the rollout, and run the chart test:
Terminal window # Terminal: on the kind-dev context, after the preparation abovehelm install myapp deploy/chart -f deploy/chart/values-staging.yaml \-f deploy/chart/values-kind.yaml --waitkubectl rollout status deploy/myapphelm test myappA rollout that finishes proves the probes, security context, and ports agree with the code. This is the check no linter replaces.
- Load the image. A kind node cannot pull an image that exists only in your local Docker:
-
Assign the sign-off. The service owner approves on the evidence from steps 1–4 and the tables Claude produced. A change to the security context, NetworkPolicy, RBAC, or secret wiring also needs the platform owner, the second reviewer your evidence bundle names for sensitive paths.
Put steps 1–3 in scripts/check-containers.sh so the hook, the session, and your CI job run the same commands. The image scan needs a built image, so CI runs trivy image after the build step:
#!/usr/bin/env bash# scripts/check-containers.sh: one gate for the hook, the session, and CIset -euo pipefail
find . -name 'Dockerfile*' -not -path './node_modules/*' -print0 | xargs -0 -r hadolinttrivy config --exit-code 1 deploy/kubeconform -strict -summary -ignore-missing-schemas deploy/k8s/for values in deploy/chart/values-*.yaml; do helm template myapp deploy/chart -f "$values" | kubeconform -strict -summary -ignore-missing-schemasdoneThe pipeline side is on pipeline configuration with Claude Code.
Debug a live cluster with the Kubernetes MCP server
Section titled “Debug a live cluster with the Kubernetes MCP server”For “why is this pod crash-looping” questions, the Kubernetes MCP server (community, containers GitHub org; npm kubernetes-mcp-server 0.0.67 and about 2.1k GitHub stars, both read 2026-09-26) gives Claude structured access to pods, logs, and events instead of parsing kubectl output. Its runtime settings live in a TOML file, and read_only = true is the setting to start with:
# Terminal: ~/.config/k8s-mcp.toml contains the line read_only = trueclaude mcp add -s user kubernetes -- npx -y kubernetes-mcp-server@0.0.67 --config ~/.config/k8s-mcp.tomlScope it to your user (-s user) rather than the project, because the kubeconfig it reads is yours. With it connected, a prompt such as “Why is the checkout pod in namespace staging in CrashLoopBackOff? Read its logs and events, then propose a manifest fix as a diff; do not apply it.” returns a diagnosis grounded in the cluster’s own events. Other infrastructure servers are compared on infrastructure MCP servers.
What breaks when Claude writes your container config?
Section titled “What breaks when Claude writes your container config?”The base image tag or digest does not exist. Claude wrote a plausible node:22.9-alpine3.21 or a digest from memory, and the build fails on pull. Recovery: tell Claude to resolve every FROM with docker buildx imagetools inspect and paste the output into its reply. The CLAUDE.md rule above prevents the repeat.
Every code change rebuilds all dependencies. COPY . . comes before the dependency install, so any source edit invalidates the cache. Recovery: “Reorder the Dockerfile so the dependency layer depends only on the lockfile, then prove it: change a source file, rebuild, and show that the install step reports CACHED.”
The smoke deploy hangs in ImagePullBackOff. The kind node looked for myapp:test in a registry, because the image was never loaded into the cluster or the pull policy is Always. Recovery: run kind load docker-image myapp:test --name dev, set pullPolicy: IfNotPresent, and never let Claude “fix” it by pushing the image to a shared registry: the deny list and the CLAUDE.md rule both forbid that.
The smoke-deploy rollout never finishes. helm install fails with no matches for kind "ExternalSecret", or the pods sit in CreateContainerConfigError or never turn ready. The kind cluster is missing a CRD, a Secret, or a dependency that /readyz checks. Recovery: run kubectl get events --sort-by=.lastTimestamp and kubectl describe pod, then add the missing piece to the kind preparation (the values-kind.yaml flag, the stand-in Secret, or the throwaway Postgres and Redis), not to the chart’s production defaults.
Pods restart with OOMKilled. The memory limit is below the real working set. Recovery: read kubectl describe pod for the last state, then ask Claude to size requests and limits from the container_memory_working_set_bytes metric your cluster already exports, not from a guess. Ongoing dashboards are covered in monitoring with Claude Code.
Claude makes a failing probe pass by loosening it. Asked to fix a failing readiness probe, the agent raises failureThreshold to 60 or points the probe at /healthz. The pod goes green and still serves traffic before the database connects. Recovery: state in the prompt that probe paths and thresholds are fixed and the fix must be in the application; review any diff that touches a probe as a sensitive change.
The readiness probe returns 200 while the app cannot serve. A /readyz that only returns 200 proves the process is up. Recovery: “Make /readyz check that the database pool has a free connection and Redis answers PING, each with a 500 ms timeout, and return 503 with the failing dependency named in the body.”
The chart works in staging and fails in production. A template references a key that only values-staging.yaml defines. Recovery: “Render the chart with each values file, list every .Values key the templates use, and show which values file leaves each one undefined.” Then add the production render to the kubeconform gate.
Compose volumes are owned by root on the host. The container’s UID differs from yours, so files it writes are read-only for you. Recovery: “Add a USER_UID build argument defaulting to 1000 for the development stage only; keep UID 10001 in the runtime stage.”
The image fails with exec format error on another machine. It was built for arm64 on an Apple silicon laptop and runs on amd64 nodes. Recovery: build with docker buildx build --platform linux/amd64,linux/arm64, and have CI publish the multi-architecture manifest rather than any laptop.