teachyou.ai academy
← All posts
Claude Code

Claude Code for Infrastructure as Code: Terraform and Beyond

Ira Menon · Jun 22, 2026 · 15 min read

The 2am Terraform Plan Nobody Wants to Read

Every infrastructure engineer knows the feeling. You run terraform plan, the output scrolls past for four hundred lines, and somewhere in there is a single change that will either be completely harmless or take down production. Maybe it's a security group rule getting replaced instead of updated. Maybe it's a for_each key that shifted and now Terraform wants to destroy and recreate twelve resources. You squint at the plus and minus signs and hope you caught it.

This is exactly the kind of pattern-matching-under-fatigue problem that pairs well with an agentic coding tool. Claude Code was built for reading code, understanding intent, and reasoning about consequences across files — and Infrastructure as Code (IaC) is, at the end of the day, just code. Terraform, Pulumi, CloudFormation, and Kubernetes manifests all describe desired state in a structured language, which means the same skills that make Claude Code useful for a Python refactor apply directly to a VPC redesign.

This article walks through how to actually use Claude Code for IaC work: writing modules, reviewing plans, refactoring sprawling .tf files, hunting drift, and building guardrails so an agent with terraform apply in its command palette doesn't become the incident. We'll use Terraform as the primary example since it's the most widely adopted IaC tool, but the workflow generalizes to Pulumi, CDK, and Ansible.

Why Infrastructure Code Is a Good Fit for an Agent

IaC has a few properties that make it particularly well suited to agentic assistance, more so than a lot of application code:

  • It's declarative. You describe the end state, not the steps to get there. This means Claude Code can reason about "what should exist" rather than simulating execution order in its head.
  • It's verifiable before it's destructive. terraform plan gives you a dry run. That's a built-in safety net that most application code doesn't have — you rarely get a "plan" step before deploying a Node service.
  • It's repetitive across environments. The same VPC module gets copy-pasted for staging, prod, and three regional replicas with minor variable changes. That's exactly the kind of mechanical transformation an LLM handles well.
  • It's full of implicit conventions. Tagging standards, naming schemes, module boundaries — things that live in a wiki page nobody reads. Claude Code can be told these conventions once (via a CLAUDE.md file) and then apply them consistently, which is more reliable than hoping every engineer remembers the tagging RFC from eighteen months ago.

The flip side is also worth naming honestly: IaC mistakes are expensive. A bad git revert costs you an awkward Slack message. A bad terraform apply costs you a postmortem. So the workflow below leans heavily on plan-first, human-approved patterns rather than "let the agent apply things."

Setting Up a Terraform-Aware CLAUDE.md

The single highest-leverage thing you can do before turning Claude Code loose on an infrastructure repo is write a good CLAUDE.md. This is the file Claude Code reads automatically at the start of a session, and for IaC repos it should encode the tribal knowledge that normally lives in someone's head.

Here's a realistic starting point for a Terraform monorepo:

# Infrastructure repo conventions

## Structure
- `modules/` contains reusable modules. Never put environment-specific
  values inside a module — pass them in as variables.
- `envs/staging`, `envs/prod` are root modules that call shared modules.
- State is stored in S3 with DynamoDB locking. Backend config lives in
  `backend.tf` in each env directory — never change the bucket/key by hand.

## Rules
- Every resource must have a `Name` and `team` tag. Use the `tags = merge(local.common_tags, {...})`
  pattern already used throughout the repo.
- Never hardcode account IDs or ARNs. Use data sources or SSM parameters.
- Security group rules must reference other security groups or specific
  CIDR blocks — no `0.0.0.0/0` on ingress except for the public ALB module.
- Run `terraform fmt -recursive` and `tflint` before proposing any diff.
- Do not run `terraform apply` or `terraform destroy` under any circumstances.
  Always stop after `terraform plan` and let a human review.

## Workflow expectations
- When asked to add a resource, first check if an existing module covers
  it before writing new HCL.
- When modifying shared modules, list every root module that consumes it
  and flag which environments will be affected by the change.

That last line under "Workflow expectations" is doing a lot of work. Shared Terraform modules are a classic blast-radius trap — a small tweak to modules/networking might quietly affect five environments, and an agent (or a junior engineer) that doesn't check consumers first can cause a lot of pain.

Writing a New Module with Claude Code

Let's walk through a concrete example: adding a new S3 bucket module with versioning, encryption, and a lifecycle policy, following patterns already in the repo. A reasonable prompt to Claude Code looks like:

Add a new reusable module at modules/s3-bucket that provisions a
private S3 bucket with versioning enabled, SSE-KMS encryption using
a customer-managed key, and a lifecycle rule that transitions objects
to Glacier after 90 days. Follow the variable/output conventions used
in modules/dynamodb-table. Do not apply, just write the files.

Claude Code will typically read the existing modules/dynamodb-table directory first to infer the conventions (variable naming, output structure, how tags are merged), then produce something like this:

# modules/s3-bucket/main.tf

resource "aws_kms_key" "this" {
  description             = "KMS key for ${var.bucket_name} bucket encryption"
  deletion_window_in_days = 30
  enable_key_rotation     = true
  tags                    = local.tags
}

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name
  tags   = local.tags
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.this.arn
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    id     = "glacier-transition"
    status = "Enabled"

    transition {
      days          = 90
      storage_class = "GLACIER"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket                  = aws_s3_bucket.this.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

locals {
  tags = merge(var.tags, {
    Name = var.bucket_name
  })
}
# modules/s3-bucket/variables.tf

variable "bucket_name" {
  description = "Name of the S3 bucket"
  type        = string
}

variable "tags" {
  description = "Common tags applied to all resources in this module"
  type        = map(string)
  default     = {}
}
# modules/s3-bucket/outputs.tf

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}

output "bucket_id" {
  value = aws_s3_bucket.this.id
}

output "kms_key_arn" {
  value = aws_kms_key.this.arn
}

Notice what's happening here: the aws_s3_bucket_public_access_block resource wasn't explicitly requested. A well-instructed agent adds it anyway because "private S3 bucket" implies blocking public access as a baseline expectation, and because the CLAUDE.md rules established a security-conscious default posture. This is the pattern worth building toward — not "generate HCL from a prompt" but "generate HCL that matches how your team actually writes HCL."

Reading Terraform Plans Instead of Skimming Them

This is arguably the single most useful day-to-day application of Claude Code in an IaC workflow: making sense of a terraform plan output before it goes anywhere near apply.

Instead of eyeballing the diff, pipe it into a review request:

terraform plan -out=tfplan
terraform show -json tfplan > plan.json

Then ask Claude Code directly:

Read plan.json and summarize what will change. Specifically call out:
1. Any resource being destroyed and recreated (not just updated in place)
2. Any change to IAM policies, security groups, or network ACLs
3. Anything that looks like an unintended side effect of the module change
   in modules/networking/main.tf

Because Claude Code can read the JSON plan output directly, it isn't guessing from the human-readable summary — it can trace exactly which attribute change forced a replacement versus an in-place update. This matters because Terraform's replace-vs-update distinction is often non-obvious. Changing an aws_instance's ami triggers a replace. Changing its tags doesn't. Changing an aws_db_instance's identifier forces a full database recreation, which is the kind of thing you really want flagged before it happens rather than after.

A useful habit: ask Claude Code to specifically look for "action": "delete" paired with "action": "create" on resources with the same logical name — that's the JSON-level signature of a destroy-and-recreate, and it's easy to miss in the CLI's colored output when you're skimming fast.

Refactoring Sprawling State Without Breaking It

Most Terraform repos accumulate cruft: a 2,000-line main.tf that should have been split into modules three years ago, resources that were never migrated when the team restructured, count loops that should be for_each. This is a place where Claude Code's ability to hold a lot of context and do mechanical, careful transformations pays off — but state management is also where the real danger lives.

The critical rule: refactoring HCL and refactoring state are two different operations, and only one of them is reversible by re-running `plan`.

If you ask Claude Code to convert a count-based resource block to for_each, the HCL diff is easy. But Terraform now sees these as entirely different resource addresses (aws_instance.web[0] versus aws_instance.web["primary"]), and without a corresponding terraform state mv, it will plan to destroy and recreate everything. A good prompt makes this explicit:

Convert the count-based aws_instance.web block in envs/prod/main.tf to
use for_each with keys from var.instance_names. Also generate the exact
terraform state mv commands needed to remap existing state addresses
so this is a no-op from Terraform's perspective. List the commands
separately from the HCL changes — do not run them.

A reasonable response includes both the refactored HCL and a script like:

# Run these AFTER reviewing, BEFORE applying the new HCL
terraform state mv 'aws_instance.web[0]' 'aws_instance.web["primary"]'
terraform state mv 'aws_instance.web[1]' 'aws_instance.web["secondary"]'
terraform state mv 'aws_instance.web[2]' 'aws_instance.web["tertiary"]'

Then, critically, run terraform plan again after the state moves and before any apply. If the plan shows "no changes," the refactor was truly a no-op. If it shows anything else, stop and investigate — don't apply based on the assumption that the state moves worked.

This is a good example of a broader principle for using Claude Code on infrastructure: treat it as extremely good at generating the mechanical parts (the HCL, the state mv commands, the migration script) and keep the verification step (plan, diff review, staging rollout) entirely in human hands.

Drift Detection and Explaining "Why Is Prod Different From Code"

Drift — where the real infrastructure no longer matches what's in your .tf files, usually because someone made a console change under time pressure — is one of the most common and most annoying IaC problems. terraform plan will show you drift, but it won't tell you why it happened or whether it's safe to reconcile.

A practical workflow:

terraform plan -refresh-only -out=drift.tfplan
terraform show -json drift.tfplan > drift.json
Read drift.json. For each drifted attribute, tell me:
- What the current real-world value is vs. what's in code
- A plausible reason someone might have made this change manually
  (e.g. "increased desired_count during an incident and never reverted")
- Whether reconciling back to code is likely safe, or whether we should
  update the .tf file to match reality instead

This turns drift detection from "a list of scary diffs" into "a triage list with recommended actions," which is a meaningfully different and more actionable artifact. It's also a good candidate for a scheduled job — running a refresh-only plan on a cron and having Claude Code summarize any new drift into a ticket or Slack message, so drift gets caught within a day instead of being discovered during the next real change.

Reviewing IaC Pull Requests

Beyond writing infrastructure code, Claude Code is useful as an additional reviewer on infrastructure pull requests — not a replacement for a human approver, but a second pass that catches the categories of mistakes that are easy to miss in a GitHub diff view (which, notably, doesn't show you the plan output at all unless you've wired up a CI bot for that).

A review-focused prompt for a PR branch:

Review the diff between main and this branch for the infra repo.
Check specifically for:
1. New resources missing required tags per our CLAUDE.md conventions
2. Any security group or IAM policy that's broader than what's needed
   for the stated purpose in the PR description
3. Hardcoded values that should be variables or data sources
4. Whether this change affects modules/ that other environments depend on
Give me a short list, not a full walkthrough — just the things worth
a second look before merge.

Framing it as "a short list of things worth a second look" rather than "review everything" keeps the output usable. Nobody wants a forty-bullet-point review on a five-line PR, and asking for a scoped, prioritized response tends to produce genuinely useful signal instead of noise.

One pattern worth calling out: security-group and IAM diffs are exactly where a second reviewer earns its keep, because the failure mode there is silent — an overly broad ingress rule doesn't break any tests, it just sits there as a latent risk until someone finds it. Terraform's plan output tells you a security group rule changed; it doesn't tell you whether the new CIDR block is appropriate. That judgment call benefits from a reviewer — human or agent — that's specifically looking for it rather than a rubber stamp.

Guardrails: What Not to Automate

It's worth being explicit about where the line should sit, because "AI writes infrastructure code" is a phrase that makes a lot of platform engineers nervous, reasonably.

  • Never give an agent unattended `apply` or `destroy` permissions. The value of Claude Code here is in drafting, explaining, and reviewing — the actual state-changing command should always pass through a human, ideally via a CI pipeline with its own approval gate, not a terminal session running unattended.
  • Keep state operations behind explicit confirmation. terraform state rm, state mv, and import all edit the state file directly and can silently orphan or duplicate resources if done wrong. Treat every state command Claude Code suggests as a proposal to review, not a script to pipe into a shell.
  • Don't let module refactors skip the plan step. As covered above, a clean-looking HCL diff can still produce a destructive plan. The plan is the source of truth, not the diff.
  • Scope credentials tightly. If Claude Code is running in an environment with AWS/GCP/Azure credentials available to its shell, those credentials should be read-only or plan-only roles wherever possible — not full admin. This is a good practice independent of AI tooling, but it matters more once an agent has a command line.
  • Version-control your CLAUDE.md conventions like you version-control the infrastructure itself. As the team's tagging policy or module structure evolves, the instructions file needs to evolve with it, or the agent will keep applying last year's conventions.

None of this is unique to Claude Code specifically — it's the same discipline you'd want around any automation touching production infrastructure, including a junior engineer's first few weeks with apply access. The agent doesn't change the safety model; it just means the safety model needs to actually be enforced through tooling (CI gates, read-only credentials, required plan review) rather than assumed.

Beyond Terraform: Pulumi, CDK, and Kubernetes Manifests

Everything above generalizes reasonably well to other IaC tools, with small adjustments:

  • Pulumi (TypeScript, Python, or Go) benefits even more from Claude Code because it's regular code with real type checking — a bad refactor is more likely to get caught by the compiler before it ever reaches a preview step. The equivalent of terraform plan is pulumi preview, and the same "read the preview output, don't just read the diff" habit applies.
  • AWS CDK compiles down to CloudFormation, so the review workflow shifts to reading cdk diff output, which shows both the CDK-level and the underlying CloudFormation-level changes. Asking Claude Code to explain a CDK diff in terms of what CloudFormation will actually do is a genuinely useful translation layer, since CDK's abstractions can obscure what's really being provisioned underneath.
  • Kubernetes manifests and Helm charts don't have as clean a "plan" step, but kubectl diff -f and helm diff upgrade (via the Helm diff plugin) provide something close. The same pattern applies: generate or refactor the YAML with Claude Code, then insist on reviewing the diff against the live cluster state before applying anything, and never let an agent run kubectl apply against a production context directly.
  • Ansible playbooks are more procedural than declarative, which makes drift detection fuzzier, but --check mode (Ansible's dry-run) plays the same role as terraform plan, and the same review-before-apply discipline holds.

The common thread across all of these tools is that IaC's built-in dry-run mechanisms — plan, preview, diff, check — are what make agentic assistance safe to use here at all. If your infrastructure tooling doesn't have a dry-run step, that's worth fixing before introducing an AI coding agent into the workflow, not after.

Getting Comfortable With the Workflow

The pattern that emerges across all of this isn't "let Claude Code manage your infrastructure." It's closer to: Claude Code drafts and explains, dry-run tooling verifies, a human approves, and the actual state-changing command runs through a pipeline with its own guardrails. That's a genuine speedup — writing a new module, understanding a gnarly plan, tracing drift, or reviewing a PR all get faster — without moving the blast radius of a mistake onto an unsupervised process.

If you're newer to Claude Code itself and want to build these habits from the ground up — how to structure a CLAUDE.md, how to scope permissions, how to work with an agent across a multi-file repo before pointing it at anything as consequential as production infrastructure — that foundation is exactly what our Claude Code Tutorial for Beginners course on teachyou.ai covers. It's a good place to start before you bring an agent anywhere near your Terraform state file.