Infrastructure as code is where AI coding agent mistakes have their most serious consequences. A wrong terraform apply can destroy a production database. A malformed Kubernetes manifest can take down a cluster. An Ansible playbook with unchecked become: yes can silently escalate privilege across dozens of hosts.
The CLAUDE.md and AGENTS.md instruction files that work fine for application code need a different design for IaC. This article provides production-ready templates for all three major IaC tools — Terraform, Kubernetes/Helm, and Ansible — along with PreToolUse hooks that enforce safety gates the model cannot override, and a hierarchical scoping pattern for managing different safety levels across dev, staging, and production environments.
Most existing guides cover only Terraform. This is the multi-tool reference.
Why IaC Rules Are Different
Application code mistakes are usually recoverable: a bad function gets rolled back, a broken test gets fixed, a UI bug gets patched. IaC mistakes operate at a different failure class:
terraform destroy -auto-approveon a production state file: data losskubectl delete namespace production: service outage, potential data lossansible-playbook --become site.ymlon the wrong inventory: mass configuration drift
Two design principles for IaC instruction files follow from this:
1. The agent produces diffs, not deployments.
Claude’s role is generating plans, writing resource definitions, and producing PRs. Applying changes to live infrastructure is a human action, always through your CI/CD pipeline.
2. Prohibitions must be machine-enforced, not just instructed.
A CLAUDE.md rule saying “don’t run terraform apply” is a suggestion the model can ignore under pressure or misunderstanding. A PreToolUse hook that exits 2 when it sees terraform apply is a gate the model cannot bypass regardless of what you ask.
Terraform CLAUDE.md Template
# CLAUDE.md — Terraform / OpenTofu Projects
## Toolchain
- Primary: Terraform >= 1.9 (or OpenTofu >= 1.8)
- State backend: S3 + DynamoDB lock (see environments/backend.tf)
- Linting: tflint (required), checkov (security scan)
- Formatting: terraform fmt (run before every commit)
## Repository Layout
├── modules/ # Reusable modules (internal source of truth) │ ├── networking/ │ ├── compute/ │ └── database/ ├── environments/ │ ├── dev/ # Lowest blast radius │ ├── staging/ # Mirrors prod config │ └── prod/ # Human-only deployments ├── CLAUDE.md # This file └── .claude/ └── settings.json # Hook configuration
## Permitted Operations
- terraform fmt
- terraform validate
- terraform plan (read-only output)
- tflint --recursive
- checkov -d .
- terraform init (download providers)
- Reading state: terraform state list, terraform state show
## Prohibited Commands (hooks enforce this — do not attempt)
- terraform apply (any form, including -auto-approve)
- terraform destroy
- terraform state rm / terraform state mv
- terraform force-unlock
- terraform taint / terraform untaint
- Direct state file manipulation
## Code Conventions
### Resource Naming
Pattern: {environment}-{service}-{resource_type}
Examples: prod-api-sg, staging-rds-primary, dev-eks-cluster
### Required Tags (all resources)
```hcl
tags = {
Environment = var.environment
Team = var.team
ManagedBy = "terraform"
Repository = "github.com/org/repo"
}
Variables
- All variables must have a description
- Use validation blocks on variables that have constraints
- Sensitive variables: sensitive = true, never output them
Module Usage
- Always check modules/ before writing a new resource from scratch
- Reference internal modules first; external registry modules second
- Pin all provider and module versions exactly
Lifecycle Rules
- All stateful resources (RDS, S3, DynamoDB, EKS): lifecycle { prevent_destroy = true }
- Never use count for resources that have unique identity — use for_each with a set of strings
State Management
- Never modify environments/prod/ without explicit written instructions
- One workspace per environment (not terraform.workspace-based switching)
- State must be in remote backend before any review
What to Do When Asked to Apply Changes
- Generate the plan: terraform plan -out=tfplan
- Save the plan output to a file: terraform show -json tfplan > plan.json
- Create a PR with the changes and the plan output as a comment
- Ask the human to review and apply through CI
Never suggest running apply directly. Always route through the PR workflow.
Security Defaults
- No 0.0.0.0/0 ingress on port 22, 3389, or any database port
- S3 buckets: block_public_acls = true, block_public_policy = true
- Encryption at rest: required on all storage resources
- KMS customer-managed keys for anything handling PII
- IAM: minimum required permissions, no wildcard actions on sensitive resources
## Kubernetes and Helm CLAUDE.md Template
```markdown
# CLAUDE.md — Kubernetes / Helm Projects
## Toolchain
- Kubernetes: >= 1.30 (check cluster version before writing manifests)
- Package manager: Helm >= 3.15
- Validation: kubectl --dry-run=client, helm lint, kubeval, kube-score
- Secrets: External Secrets Operator (ESO) or sealed-secrets — never raw Secret manifests with base64 data
- GitOps: ArgoCD (watch this repo's clusters/ directory)
## Repository Layout
├── clusters/ │ ├── dev/ # Kustomize overlays for dev │ ├── staging/ # Kustomize overlays for staging │ └── prod/ # Kustomize overlays for prod ├── base/ # Kustomize base manifests ├── charts/ # Internal Helm charts │ └── my-service/ │ ├── Chart.yaml │ ├── values.yaml │ └── templates/ ├── CLAUDE.md └── .claude/ └── settings.json
## Permitted Operations
- kubectl apply --dry-run=client -f manifest.yaml
- kubectl diff -f manifest.yaml
- helm lint ./charts/my-service
- helm template ./charts/my-service --debug
- helm upgrade --dry-run release-name ./charts/my-service
- kubectl get / kubectl describe / kubectl logs (read-only verbs)
- kustomize build clusters/dev/
## Prohibited Commands (hooks enforce this — do not attempt)
- kubectl delete (any resource, any namespace)
- kubectl apply without --dry-run=client (hooks check for this)
- helm install / helm upgrade (without --dry-run)
- helm uninstall
- kubectl exec -it (interactive shell into pods)
- kubectl port-forward (creates live connections)
- Any command targeting the prod namespace or prod cluster context
## Manifest Conventions
### Namespaces
- Never deploy to the default namespace
- All workloads: namespace explicitly declared in metadata
- RBAC: ClusterRoles only when namespace-scoped is insufficient
### Resource Limits (required on every container)
```yaml
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
Health Probes (required on every container)
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
Secrets
- Never create raw Kubernetes Secret manifests with base64 data in this repo
- Use ExternalSecret CRD (ESO) referencing the vault path
- For local dev only: use kubectl create secret from values not committed
# Correct pattern
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-service-secrets
spec:
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: my-service-secrets
data:
- secretKey: DATABASE_URL
remoteRef:
key: my-service/database
property: url
Helm Chart Conventions
- Chart.yaml: version and appVersion must match on every release
- values.yaml: document every value with a comment
- templates/: use _helpers.tpl for repeated label sets
- Never hardcode namespaces inside chart templates — use .Values.namespace
Rollout Strategy
- Deployments: RollingUpdate with maxUnavailable: 0 and maxSurge: 1
- StatefulSets: OnDelete strategy (no auto-rolling updates on stateful services)
- Database schema changes: init containers or separate migration job before Deployment rollout
What to Do When Asked to Apply Changes
- Generate dry-run output: kubectl diff or helm upgrade —dry-run
- Run validation: helm lint, kube-score
- Create a PR — ArgoCD syncs from main automatically on merge to prod
- Never apply directly, even in dev. Let ArgoCD do it.
RBAC
- No ClusterAdmin for service accounts
- Workload identity (IRSA/Workload Identity) over static credentials
- NetworkPolicies: default-deny-all in every namespace, explicit allow for required communication
## Ansible CLAUDE.md Template
```markdown
# CLAUDE.md — Ansible Projects
## Toolchain
- Ansible Core: >= 2.17
- Linting: ansible-lint (run before every commit)
- Testing: molecule (unit), --check mode (dry-run)
- Vault: ansible-vault for all secrets (no plaintext vars files with secrets)
## Repository Layout
├── inventories/ │ ├── dev/ # dev hosts and group_vars │ ├── staging/ │ └── prod/ # Explicit human authorization required ├── roles/ # Reusable roles ├── playbooks/ # Orchestration entry points ├── group_vars/ # Shared variables ├── CLAUDE.md └── .claude/ └── settings.json
## Permitted Operations
- ansible-lint playbooks/ roles/
- ansible-playbook --check --diff -i inventories/dev/ playbooks/site.yml
- ansible-inventory --list -i inventories/dev/
- molecule test (within a role directory)
- ansible-vault encrypt_string (create encrypted values)
- ansible-vault view (view encrypted content)
## Prohibited Commands (hooks enforce this — do not attempt)
- ansible-playbook without --check (live execution)
- ansible-playbook -i inventories/prod/ (production inventory)
- ansible-playbook --become without explicit written authorization
- ansible -m shell -a (ad-hoc commands in any inventory)
- ansible-vault rekey (key rotation without human oversight)
- Any command using the ALL group against prod
## Playbook Conventions
### Task Naming
Every task must have a name. The name must describe the desired state, not the action:
```yaml
# Wrong
- name: Run script
command: /usr/local/bin/setup.sh
# Correct
- name: Initialize application database schema
command: /usr/local/bin/setup.sh
args:
creates: /var/app/db/.initialized
Idempotency
- All tasks must be idempotent. If using command/shell, use creates/removes or register + when
- Prefer Ansible modules over command/shell whenever a module exists
- Always test idempotency: two consecutive —check runs should show no changes on the second
Variables
# group_vars/all/main.yml — safe defaults
app_port: 8080
app_user: appuser
deploy_dir: /opt/app
# group_vars/prod/main.yml — prod overrides (committed, no secrets)
app_replicas: 3
enable_monitoring: true
# Never commit — use ansible-vault
# group_vars/prod/vault.yml (vault-encrypted file)
# vault_db_password: "..."
# vault_api_key: "..."
Become (Privilege Escalation)
- Default become: false in all playbooks
- Declare become: true only on tasks that genuinely require root
- Never set become: yes at the playbook level unless every task in the play requires it
- Document why privilege escalation is needed in a comment above the task
- name: Install nginx package
become: true # requires root for package installation
ansible.builtin.package:
name: nginx
state: present
Handlers
- All service restarts must be handlers, not tasks
- Never restart a service in a task — use notify + handler pattern
- Handler names must be unique across the entire play
Secrets
- All secrets encrypted with ansible-vault
- Vault password via vault_password_file (file not committed) or ANSIBLE_VAULT_PASSWORD_FILE env var
- Never echo or debug vault variables
- Rotate vault keys when team membership changes
Testing with Molecule
# Standard test cycle
cd roles/my-role/
molecule create # spin up test instance
molecule converge # apply the role
molecule idempotency # verify second run shows no changes
molecule verify # run test assertions
molecule destroy # clean up
What to Do When Asked to Run a Playbook
- Run ansible-lint to catch issues
- Run with —check —diff on dev inventory: ansible-playbook —check —diff -i inventories/dev/ playbooks/site.yml
- Review the diff output and create a PR
- Staging/prod execution: human runs with explicit authorization, after change management approval
## Hook-Based Safety Gates
The CLAUDE.md rules above tell the model what to do. Hooks enforce it mechanically. Place this in `.claude/settings.json` in your IaC repository root:
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/iac-safety-gate.sh"
}
]
}
]
}
}
The safety gate script:
#!/bin/bash
# ~/.claude/hooks/iac-safety-gate.sh
# Reads CLAUDE_TOOL_INPUT from environment (JSON with command field)
COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | jq -r '.command // ""')
# Terraform: block destructive operations
if echo "$COMMAND" | grep -qE "terraform\s+(apply|destroy|force-unlock)"; then
echo "BLOCKED: terraform apply/destroy/force-unlock must run through CI/CD pipeline, not the AI agent." >&2
echo "To proceed: create a PR, let CI run terraform plan, and merge to trigger the pipeline." >&2
exit 2
fi
# Terraform: block state manipulation
if echo "$COMMAND" | grep -qE "terraform\s+state\s+(rm|mv|push|force-push)"; then
echo "BLOCKED: Direct state manipulation requires human authorization. Use the operations runbook." >&2
exit 2
fi
# Kubernetes: block delete operations
if echo "$COMMAND" | grep -qE "kubectl\s+delete\b"; then
echo "BLOCKED: kubectl delete is not permitted through the AI agent. Use GitOps (ArgoCD/Flux) to remove resources." >&2
exit 2
fi
# Kubernetes: block live apply (without dry-run)
if echo "$COMMAND" | grep -qE "kubectl\s+apply\b" && ! echo "$COMMAND" | grep -q "dry-run"; then
echo "BLOCKED: kubectl apply must include --dry-run=client. Live applies go through ArgoCD on PR merge." >&2
exit 2
fi
# Kubernetes: block prod context
if echo "$COMMAND" | grep -qE "(--context.*prod|--kubeconfig.*prod)"; then
echo "BLOCKED: Production cluster operations are not permitted through the AI agent." >&2
exit 2
fi
# Ansible: block live execution (no --check flag)
if echo "$COMMAND" | grep -qE "ansible-playbook\b" && ! echo "$COMMAND" | grep -q "\-\-check"; then
echo "BLOCKED: ansible-playbook requires --check mode. Live execution must be authorized by a human." >&2
exit 2
fi
# Ansible: block prod inventory
if echo "$COMMAND" | grep -qE "ansible.*-i\s+inventories/prod"; then
echo "BLOCKED: Production inventory is restricted. Use dev or staging inventory for testing." >&2
exit 2
fi
# Ansible: block become without flag (ad-hoc privilege escalation check)
if echo "$COMMAND" | grep -qE "ansible\s+-[a-zA-Z]*b\b" && echo "$COMMAND" | grep -qE "ansible\s+-m\s+shell"; then
echo "BLOCKED: Ad-hoc shell commands with become are not permitted." >&2
exit 2
fi
exit 0
Make the script executable:
chmod +x ~/.claude/hooks/iac-safety-gate.sh
The hook runs before every Bash tool call. Exit code 2 blocks the command and shows the error message to Claude, which then explains the restriction to you and asks how to proceed within the safe path.
Hierarchical Per-Environment Scoping
Claude Code reads CLAUDE.md files hierarchically: the project root file is always loaded, and any CLAUDE.md in a subdirectory is added when Claude changes into that directory. Use this to set different safety levels per environment.
Directory Structure
repo/
├── CLAUDE.md # Root: always loaded, global conventions
├── environments/
│ ├── dev/
│ │ ├── CLAUDE.md # Dev: relaxed — plan + limited apply OK
│ │ └── main.tf
│ ├── staging/
│ │ ├── CLAUDE.md # Staging: plan only, no apply
│ │ └── main.tf
│ └── prod/
│ ├── CLAUDE.md # Prod: read-only, no plan output committed
│ └── main.tf
environments/dev/CLAUDE.md
# CLAUDE.md — Dev Environment Override
Environment: dev
Blast radius: low (no production data, disposable)
## Relaxed Permissions for Dev
- terraform plan and terraform apply are permitted in this directory ONLY
- Applies are still logged — document what you changed and why in the PR description
- terraform destroy is permitted for dev resources you created in this session
- Use -target flag to limit scope when applying individual resources
## Dev-Specific Defaults
- Skip prevent_destroy lifecycle rules (dev resources are disposable)
- Use placeholder values for external service credentials
- Cost budget: < $50/month — flag anything that would exceed this
## Still Prohibited
- Modifying shared state that affects staging or prod
- Creating resources outside the dev/ workspace
- Storing real credentials (use test values)
environments/staging/CLAUDE.md
# CLAUDE.md — Staging Environment Override
Environment: staging
Blast radius: medium (mirrors prod config, may have real integrations)
## Staging Permissions
- terraform plan: permitted
- terraform apply: NOT permitted — use CI pipeline
- terraform destroy: NOT permitted
- Read-only operations: permitted
## Staging-Specific Notes
- This environment replicates prod configuration. Breaking staging = breaking the pre-prod signal.
- External service credentials here are real — do not log or expose them
- Always run checkov before proposing any change: checkov -d .
## All Global Prohibitions Apply
environments/prod/CLAUDE.md
# CLAUDE.md — Production Environment Override
Environment: production
Blast radius: maximum — data loss, revenue impact, SLA breach
## Production Permissions
- Read-only operations only: terraform state list, terraform state show
- terraform plan output is permitted but must not be auto-applied
- NO write operations of any kind
## Required Before Any Production Change Proposal
1. Open a GitHub issue describing the change and its business justification
2. Reference the issue number in all PR descriptions
3. Get approval from the infrastructure team lead
4. Run the full checkov scan: checkov -d .
5. Attach the terraform plan output to the PR (never the tfplan binary)
## Emergency Procedures
If you believe an emergency change is needed:
- Stop and notify a human immediately
- Do not attempt to apply anything through the AI agent
- Use the #infra-oncall Slack channel
AGENTS.md Compact Version
For multi-agent workflows where a subagent handles IaC, an AGENTS.md provides a shorter instruction set the agent can load quickly:
# AGENTS.md — IaC Agent Instructions
## Core Rules
1. Never apply, destroy, or delete. Generate plans and PRs only.
2. Always validate before proposing: terraform validate, tflint, helm lint, ansible-lint
3. Use --check / --dry-run for any live-system command
4. Route all changes through CI/CD. Your job ends at the PR.
## Toolchain Quick Reference
- Terraform: fmt → validate → plan → PR
- Kubernetes: lint → diff (dry-run) → PR → ArgoCD syncs
- Ansible: lint → check+diff (dev inventory) → PR
## Safety Overrides
Hooks enforce prohibitions mechanically. If a command is blocked, explain the restriction to the user and propose the safe alternative workflow.
## Context Files
- Terraform conventions: /CLAUDE.md
- Per-environment rules: /environments/{env}/CLAUDE.md
- Hook configuration: /.claude/settings.json
DevOps Workflow Patterns
Pattern 1: Terraform Module Development
1. Read existing modules/ before writing new resources
2. Run: terraform fmt → terraform validate → tflint
3. Run checkov for security: checkov -d .
4. Generate plan: terraform plan -out=tfplan
5. Export plan for review: terraform show -json tfplan > plan_review.json
6. Create PR with plan_review.json as attachment
7. Human reviews + approves → CI applies via pipeline
Pattern 2: Kubernetes Manifest Update
1. Edit manifest or Helm values
2. Validate: helm lint ./charts/service
3. Diff: helm upgrade --dry-run release ./charts/service
4. Score: kube-score score manifest.yaml
5. Create PR → ArgoCD syncs on merge
Pattern 3: Ansible Role Development
1. Write task in role, add test in molecule/default/
2. Lint: ansible-lint roles/my-role/
3. Test: cd roles/my-role && molecule test
4. Check against dev: ansible-playbook --check --diff -i inventories/dev/ playbooks/site.yml
5. Create PR
Common Pitfalls
Agents running plan against the wrong workspace
Terraform workspaces are confusing. Be explicit in CLAUDE.md about workspace conventions and require the agent to confirm which workspace is selected before running any plan.
Helm values file override order
Helm applies values files left-to-right, with later files winning. Agents sometimes get the override order wrong. Specify the exact command with all -f flags in CLAUDE.md and in the hook’s approved command list.
Ansible facts caching masking state drift
Gathered facts can be stale if caching is enabled. Specify gather_facts: true (the default) in playbooks and document the cache TTL in CLAUDE.md so agents know when facts are reliable.
State lock contention
An agent running multiple terraform plan operations concurrently can hit DynamoDB lock contention. The hooks above block apply but not concurrent plans. Add serialization if your CI uses parallel Terraform runs.
Related Reading
- AGENTS.md in CI/CD: Automated Diff Review, Validation, and Drift Detection
- Claude Code Hooks: Automate Pre/Post Tool Actions and Session Events (2026)
- Claude Code GitHub Actions: Complete CI/CD Integration Guide
- AGENTS.md Security: What Not to Include
FAQ
Q: Should IaC rules go in CLAUDE.md or AGENTS.md?
Both serve different purposes. CLAUDE.md is the primary instruction file for Claude Code interactive sessions — use it for the full ruleset. AGENTS.md is a shorter version that subagents load in multi-agent workflows. Keep AGENTS.md to the core prohibitions and quick-reference conventions; link to CLAUDE.md for full detail.
Q: Do hooks run in every Claude Code session?
Hooks defined in .claude/settings.json at the project root run in every session started in that project, regardless of which CLAUDE.md files are loaded. Hooks in ~/.claude/settings.json run in every session for every project. Use the project-level settings for IaC-specific gates.
Q: Can the model work around the hooks by rephrasing the command?
Hooks match on the actual command string passed to the Bash tool, not on what Claude says it intends to do. The grep patterns in the safety gate script catch the command regardless of how it’s described in the conversation.
Q: How do I handle terraform plan output that’s too large for context?
Pipe it to a file: terraform plan -out=tfplan && terraform show tfplan > plan.txt. Ask Claude to read and summarize plan.txt rather than dumping the raw output into the context window. For very large plans, ask Claude to filter by change type: grep -A 5 "will be".
Q: Should different team members have different permission levels?
Claude Code doesn’t have per-user hook configuration natively. Use team-level settings.json committed to the repo (enforces the same gates for everyone) plus individual ~/.claude/settings.local.json for any personal overrides that don’t affect team policy. Keep destructive command blocks in the committed settings.json so they’re version-controlled.
Q: How do I allow specific humans to override the apply block in emergencies?
Add a confirmation word check in the hook: if the Bash command contains a specific token like OVERRIDE_APPLY_AUTHORIZED, allow it through. Require that token to come from a human-controlled step in your change management process, not from the AI session itself.
Protect IaC Secrets in AI Sessions
Infrastructure sessions often need credentials: cloud provider keys, vault tokens, database passwords. Keep them out of .env files that might be committed and out of shell history. 1Password CLI’s op run injects secrets into Claude Code’s session environment at runtime — your Terraform AWS credentials, Ansible vault password file path, and kubeconfig tokens never touch disk or shell history:
# claude.ai/settings or .claude/settings.json env block
op run --env-file=.op-env -- claude
# .op-env contains: AWS_ACCESS_KEY_ID=op://infra-vault/aws-dev/access_key_id