Use Cases (Updated: 6/2/2026)

Terraform IaC with Claude Code: AWS Modules, State, CI, and Plan Review

Build safer Terraform IaC with Claude Code using AWS examples, modules, remote state, CI policy checks, and plan review.

Terraform IaC with Claude Code: AWS Modules, State, CI, and Plan Review

Terraform is easy to start and surprisingly easy to misuse. A generated VPC can look correct while the state file is stored locally, the production and staging keys point to the same backend path, or a future pull request silently replaces a subnet. Claude Code helps most when you use it as an implementation reviewer, not just as a code generator.

This guide walks through a practical AWS-oriented Terraform IaC workflow. IaC means Infrastructure as Code: describing cloud resources in files that can be reviewed, tested, and versioned. HCL is Terraform’s configuration language. A module is a reusable infrastructure component. State is Terraform’s record of what exists in the real cloud account. A backend is where that state is stored. Those definitions matter because most Terraform incidents happen at the boundaries between code, state, permissions, and human review.

In a small validation repo for this article, Claude Code was useful for splitting a VPC into a clean module, adding input validation, and turning a raw terraform plan into a risk checklist. It still needed explicit guardrails: do not put secrets in tfvars, do not approve destroy actions automatically, and do not use older S3 backend locking guidance for new work.

Workflow and Use Cases

The safe pattern is: give Claude Code a constrained brief, generate HCL, run Terraform checks, review the plan, and apply only after approval.

flowchart LR
  A["Write the infrastructure brief"] --> B["Claude Code drafts HCL"]
  B --> C["terraform fmt / validate"]
  C --> D["terraform plan"]
  D --> E["AI-assisted and human review"]
  E --> F["Approved apply"]

This workflow fits several real projects.

Use caseWhat Claude Code can draftWhat a human must review
New AWS networkVPC, subnets, routes, tagsCIDR overlap, NAT Gateway cost
Module cleanupmodules/vpc structure, variables, outputsWhether the module owns too much
Environment separationenvs/dev.tfvars and envs/prod.tfvarsBackend keys and production permissions
CI and policyfmt, validate, plan, JSON policy checksDestroy, replace, and IAM expansion

Use the Claude Code docs for product basics and the Terraform modules documentation for module concepts. If IAM is the weak part of your setup, read the internal AWS IAM guide before applying infrastructure changes.

Prompt Claude Code with Constraints

The first prompt should include the boundaries, not only the desired resources. Terraform work needs a stronger brief than application scaffolding because the result can spend money, expose networks, or delete real resources.

claude -p "
Create Terraform for an AWS VPC module only.
Do not delete existing files unless I explicitly ask.

Requirements:
- Assume Terraform 1.10 or later
- Use the hashicorp/aws provider
- Create one VPC, two public subnets, two private subnets, an Internet Gateway, and one NAT Gateway
- Put reusable code under modules/vpc
- Separate dev and prod with tfvars files
- Do not write secrets or AWS access keys into tfvars
- Include terraform fmt -recursive, terraform validate, and terraform plan steps
- If the plan includes destroy or replacement, treat it as not approved
"

The important words are “module only”, “do not delete”, “no secrets”, and “destroy is not approved”. Without those constraints, an AI assistant tends to fill gaps with generic patterns. Generic patterns are not enough for Terraform because one broad IAM statement or one shared state key can become a production problem.

A Copy-Ready AWS Terraform Skeleton

The following layout is intentionally small. With AWS credentials and a real state bucket in place, it can be formatted, validated, and planned. For new S3 backends, the current Terraform documentation supports S3 state locking with use_lockfile; DynamoDB-based locking is deprecated for this backend. See the official S3 backend documentation before copying older examples.

infra/
  versions.tf
  variables.tf
  main.tf
  outputs.tf
  envs/dev.tfvars
  modules/vpc/main.tf
  modules/vpc/variables.tf
  modules/vpc/outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.10.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 7.0"
    }
  }

  backend "s3" {
    bucket       = "replace-with-your-tfstate-bucket"
    key          = "claude-code-terraform-iac/dev/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = var.common_tags
  }
}
# variables.tf
variable "aws_region" {
  type        = string
  description = "AWS region to deploy into."
  default     = "us-east-1"
}

variable "environment" {
  type        = string
  description = "Environment name such as dev, staging, or prod."
}

variable "common_tags" {
  type        = map(string)
  description = "Tags applied to every supported AWS resource."
}

variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the VPC."
}

variable "public_subnets" {
  type = list(object({
    name = string
    cidr = string
    az   = string
  }))
}

variable "private_subnets" {
  type = list(object({
    name = string
    cidr = string
    az   = string
  }))
}
# main.tf
module "network" {
  source = "./modules/vpc"

  project         = "claude-code-iac"
  environment     = var.environment
  vpc_cidr        = var.vpc_cidr
  public_subnets  = var.public_subnets
  private_subnets = var.private_subnets
  common_tags     = var.common_tags
}

Module Design and Variables

Keep the VPC module focused on networking. Do not mix ALB, ECS, RDS, and IAM policy generation into the same module unless you have a clear service boundary. A narrow module produces smaller plans and clearer reviews.

# modules/vpc/variables.tf
variable "project" {
  type        = string
  description = "Project name used in resource names."
}

variable "environment" {
  type        = string
  description = "Environment name used in tags and names."
}

variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the VPC."

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr must be a valid CIDR block."
  }
}

variable "public_subnets" {
  type = list(object({
    name = string
    cidr = string
    az   = string
  }))

  validation {
    condition     = length(var.public_subnets) >= 2 && alltrue([for s in var.public_subnets : can(cidrhost(s.cidr, 0))])
    error_message = "Define at least two valid public subnet CIDR blocks."
  }
}

variable "private_subnets" {
  type = list(object({
    name = string
    cidr = string
    az   = string
  }))

  validation {
    condition     = length(var.private_subnets) >= 2 && alltrue([for s in var.private_subnets : can(cidrhost(s.cidr, 0))])
    error_message = "Define at least two valid private subnet CIDR blocks."
  }
}

variable "common_tags" {
  type        = map(string)
  description = "Common tags."
}
# modules/vpc/main.tf
locals {
  public_subnets_by_name  = { for subnet in var.public_subnets : subnet.name => subnet }
  private_subnets_by_name = { for subnet in var.private_subnets : subnet.name => subnet }
  name_prefix             = "${var.project}-${var.environment}"
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(var.common_tags, {
    Name = "${local.name_prefix}-vpc"
  })
}

resource "aws_subnet" "public" {
  for_each                = local.public_subnets_by_name
  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value.cidr
  availability_zone       = each.value.az
  map_public_ip_on_launch = true

  tags = merge(var.common_tags, {
    Name = "${local.name_prefix}-public-${each.key}"
    Tier = "public"
  })
}

resource "aws_subnet" "private" {
  for_each          = local.private_subnets_by_name
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = merge(var.common_tags, {
    Name = "${local.name_prefix}-private-${each.key}"
    Tier = "private"
  })
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }
}

resource "aws_route_table_association" "public" {
  for_each       = aws_subnet.public
  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

resource "aws_eip" "nat" {
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = values(aws_subnet.public)[0].id
  depends_on    = [aws_internet_gateway.main]
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }
}

resource "aws_route_table_association" "private" {
  for_each       = aws_subnet.private
  subnet_id      = each.value.id
  route_table_id = aws_route_table.private.id
}
# modules/vpc/outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

output "public_subnet_ids" {
  value = [for subnet in aws_subnet.public : subnet.id]
}

output "private_subnet_ids" {
  value = [for subnet in aws_subnet.private : subnet.id]
}

State, Backend, and Environment Separation

State is not a harmless cache. It contains resource IDs and sometimes sensitive attributes. Store it in a controlled backend, enable S3 bucket versioning, and separate environment keys. The classic mistake is using the same backend key for dev and prod, then wondering why a harmless test plan touches production-like resources.

# envs/dev.tfvars
aws_region  = "us-east-1"
environment = "dev"
vpc_cidr    = "10.40.0.0/16"

common_tags = {
  Project     = "claude-code-iac"
  Environment = "dev"
  Owner       = "masa"
  ManagedBy   = "terraform"
}

public_subnets = [
  { name = "a", cidr = "10.40.0.0/24", az = "us-east-1a" },
  { name = "b", cidr = "10.40.1.0/24", az = "us-east-1b" }
]

private_subnets = [
  { name = "a", cidr = "10.40.10.0/24", az = "us-east-1a" },
  { name = "b", cidr = "10.40.11.0/24", az = "us-east-1b" }
]

For production, change the CIDR range, tags, and backend key. Workspaces can help advanced teams, but separate tfvars files and explicit backend keys are easier for new teams to review.

Verification: fmt, validate, plan, apply

Terraform documents the main CLI steps in the official pages for fmt, validate, and plan. Start with backend-free validation so you can catch syntax and type errors without touching remote state.

cd infra
terraform init -backend=false
terraform fmt -recursive
terraform validate

When you are ready to inspect real AWS changes, initialize the backend and save the plan.

terraform init -reconfigure
terraform plan -var-file=envs/dev.tfvars -out=tfplan
terraform show -no-color tfplan > plan.txt

Only apply the reviewed plan.

terraform apply tfplan

Ask Claude Code to review the plan with a destructive-change bias.

claude -p "
Review plan.txt.
Prioritize destroy, replacement, IAM expansion, public subnet exposure, and backend key changes.
Do not approve the plan if deletion is present.
Return a short risk list and the exact questions a human reviewer should answer.
"

CI, Policy, Permissions, and Secrets

For GitHub Actions, avoid long-lived AWS access keys. Use OIDC to assume a narrowly scoped IAM role; the official GitHub guide for OIDC in AWS covers the trust relationship. The role itself should follow AWS IAM best practices.

name: terraform-plan

on:
  pull_request:
    paths:
      - "infra/**"

permissions:
  contents: read
  id-token: write

jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra

    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.10.0"
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-terraform-plan
          aws-region: us-east-1
      - run: terraform init -backend=false
      - run: terraform fmt -check -recursive
      - run: terraform validate
      - run: terraform init -reconfigure
      - run: terraform plan -var-file=envs/dev.tfvars -out=tfplan
      - run: terraform show -json tfplan > tfplan.json

A simple policy gate can block destructive plans and risky public-access changes.

package terraform.deny

deny[msg] {
  rc := input.resource_changes[_]
  rc.change.actions[_] == "delete"
  msg := sprintf("Destroy action requires manual approval: %s", [rc.address])
}

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "aws_s3_bucket_public_access_block"
  after := rc.change.after
  after.block_public_acls == false
  msg := sprintf("S3 public ACL blocking is disabled: %s", [rc.address])
}

For broader pipeline design, continue with the internal Claude Code CI/CD guide and the secrets management guide.

Common Failure Modes

The first failure is treating state like a temporary file. Do not commit state, do not share one backend key across environments, and do not grant every developer full write access to production state.

The second failure is using count.index for long-lived resources. Reordering a list can produce replacements. Named for_each maps make the plan easier to understand.

The third failure is asking Claude Code to “apply this” without a review brief. Claude Code can summarize risk, but it does not own downtime, public exposure, or AWS cost. NAT Gateway, RDS, EKS, and IAM changes deserve manual inspection.

The fourth failure is putting secrets in tfvars. A sensitive = true output reduces display noise, but it does not make state magically safe. Use CI secrets, AWS Secrets Manager, or another controlled secret store.

Results and Next Step

In the test repo for this article, terraform fmt -recursive and terraform validate passed cleanly after Claude Code split the VPC into a module. The plan review surfaced three practical issues: the placeholder backend bucket blocks real initialization, NAT Gateway cost is easy to miss, and shared backend keys are dangerous even in a demo. The best Claude Code prompt was the one that explicitly ranked destroy and replace above all style feedback.

If your team wants reusable review prompts, CI checklists, and IaC onboarding material, use the Claude Code product library or book implementation training. The highest-leverage habit is simple: put the prompt, the Terraform commands, and the destructive-change review rules into CLAUDE.md before the next infrastructure pull request.

#Claude Code #Terraform #IaC #AWS #infrastructure
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.