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 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 case | What Claude Code can draft | What a human must review |
|---|---|---|
| New AWS network | VPC, subnets, routes, tags | CIDR overlap, NAT Gateway cost |
| Module cleanup | modules/vpc structure, variables, outputs | Whether the module owns too much |
| Environment separation | envs/dev.tfvars and envs/prod.tfvars | Backend keys and production permissions |
| CI and policy | fmt, validate, plan, JSON policy checks | Destroy, 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.