用 Claude Code 实现 Terraform IaC:AWS 模块、状态与 CI 实战
以AWS为例讲解Claude Code如何辅助Terraform IaC,覆盖module、state、CI、plan审查和常见坑。
用 Terraform 写 AWS 基础设施时,真正难的往往不是把第一个 VPC 写出来,而是后面的治理:state 放在哪里、module 怎么拆、dev 和 prod 如何隔离、CI 怎样审查 terraform plan、Claude Code 生成的代码是否会误删资源。IaC 是 Infrastructure as Code,也就是把云资源配置写成可以审查和版本管理的代码。HCL 是 Terraform 的配置语言,module 是可复用的基础设施组件,state 是 Terraform 记录真实资源的账本,backend 是保存 state 的位置。
Claude Code 在 Terraform 场景里很有用,但它不应该被当成“自动上线按钮”。更安全的用法是:让它根据清晰的约束生成 HCL,再让它帮助审查 plan、指出破坏性变更和权限扩大。Masa 在一个小型 AWS 验证仓库里试过,Claude Code 对 module 拆分、variables 校验、CI 草稿非常高效;但如果不明确禁止 secrets 写入 tfvars、不要求 destroy 人工确认,它也可能给出过于宽泛的默认方案。
工作流与适用场景
推荐的顺序是先写需求,再生成代码,然后执行 Terraform 检查,最后审查 plan。
flowchart LR
A["写清楚基础设施需求"] --> B["Claude Code 生成 HCL 草稿"]
B --> C["terraform fmt / validate"]
C --> D["terraform plan"]
D --> E["Claude Code + 人工审查"]
E --> F["批准后 apply"]
常见的使用场景至少有四类。
| 场景 | Claude Code 可以做什么 | 人必须检查什么 |
|---|---|---|
| 新建 AWS 网络 | 生成 VPC、subnet、route table、tag | CIDR 是否冲突,NAT Gateway 成本 |
| module 化 | 拆成 modules/vpc、整理 variables 和 outputs | module 是否承担太多职责 |
| 环境隔离 | 生成 envs/dev.tfvars 和 envs/prod.tfvars | backend key 是否混用,prod 权限是否过大 |
| CI 和 policy | 生成 fmt、validate、plan、policy check | destroy、replace、IAM 扩权 |
Claude Code 的基础用法可看官方文档,Terraform module 概念可看Terraform Modules 官方文档。如果你要先补 AWS 权限设计,可以继续读站内的AWS IAM 指南。
给 Claude Code 的提示词要带约束
不要只说“帮我写 Terraform”。要把可触碰范围、禁止事项和验证命令写进去。
claude -p "
请为 AWS 创建 Terraform 配置,只处理 VPC module。
不要删除现有文件,除非我明确要求。
要求:
- 假设 Terraform 1.10 或更高版本
- 使用 hashicorp/aws provider
- 创建 VPC、2 个 public subnet、2 个 private subnet、Internet Gateway、NAT Gateway
- 可复用代码放在 modules/vpc
- dev 和 prod 用 tfvars 分离
- 不要把 secrets 或 AWS access key 写进 tfvars
- 给出 terraform fmt -recursive、terraform validate、terraform plan 的步骤
- 如果 plan 出现 destroy 或 replacement,默认不允许 apply
"
这段提示词的价值在于明确了安全边界。Terraform 不是普通脚手架,生成出来的代码会花钱、改网络、影响权限。Claude Code 可以帮你写得更快,但成本、停机和安全风险仍然要由人来审查。
可复制的 AWS Terraform 结构
下面的结构可以作为小型项目起点。真实执行前需要 AWS 凭证和已经创建好的 state bucket。2026 年 6 月时,Terraform S3 backend 支持通过 use_lockfile 开启 S3 锁;旧的 DynamoDB 锁在官方文档中已标为 deprecated,新项目应避免继续照抄老文章。参考Terraform S3 backend 官方文档。
infra/
versions.tf
variables.tf
main.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 = "ap-northeast-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 = "ap-northeast-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 设计与 variables
VPC module 只负责网络。不要把 ALB、ECS、RDS、IAM 全塞进去,否则 plan 会很大,审查也会变得模糊。下面的写法用 for_each 和具名 subnet,避免单纯依赖 count.index 造成顺序变化引发 replace。
# 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 "private_subnet_ids" {
value = [for subnet in aws_subnet.private : subnet.id]
}
state、backend 与环境隔离
state 不是普通缓存。它保存资源 ID 和部分属性,如果权限过宽,就等于把基础设施账本交给了太多人。S3 bucket 应开启 versioning,IAM 只允许需要的人访问对应环境的 state。dev 和 prod 不要共享同一个 key。
# envs/dev.tfvars
aws_region = "ap-northeast-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 = "ap-northeast-1a" },
{ name = "c", cidr = "10.40.1.0/24", az = "ap-northeast-1c" }
]
private_subnets = [
{ name = "a", cidr = "10.40.10.0/24", az = "ap-northeast-1a" },
{ name = "c", cidr = "10.40.11.0/24", az = "ap-northeast-1c" }
]
如果是 production,CIDR、tag、backend key 都要改。workspace 对成熟团队有帮助,但对初学者来说,明确的 envs/*.tfvars 加单独 backend key 更容易审查。
fmt、validate、plan 的验证步骤
Terraform 官方说明了 fmt、validate、plan 的用途。先做不连接远端 backend 的检查。
cd infra
terraform init -backend=false
terraform fmt -recursive
terraform validate
要查看真实 AWS 差异时,再初始化 backend 并保存 plan。
terraform init -reconfigure
terraform plan -var-file=envs/dev.tfvars -out=tfplan
terraform show -no-color tfplan > plan.txt
让 Claude Code 审查 plan 时,不要只让它总结,要让它优先找破坏性变更。
claude -p "
请审查 plan.txt。
优先指出 destroy、replace、force replacement、IAM 权限扩大、public subnet 暴露、backend key 修改。
如果包含删除动作,请不要批准。
请列出人工 reviewer 必须回答的问题。
"
CI、policy、权限与 secrets
在 GitHub Actions 中,不要保存长期 AWS access key。推荐用 OIDC 承担一个权限很窄的 IAM role,官方说明见GitHub Actions OIDC in AWS。IAM 最小权限可以参考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: ap-northeast-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
policy 可以把明显危险的 plan 拦下来。
package terraform.deny
deny[msg] {
rc := input.resource_changes[_]
rc.change.actions[_] == "delete"
msg := sprintf("Destroy action requires manual approval: %s", [rc.address])
}
CI 的整体设计可继续读Claude Code CI/CD 指南,secrets 管理可看Claude Code Secrets 管理。
常见失败与实际验证结果
最常见的失败是把 state 当成临时文件,提交到 Git 或放在个人电脑上。第二个失败是 count.index 用在长期资源上,列表顺序一变就可能 replace。第三个失败是把 DB 密码、API token、AWS key 写入 tfvars;即使变量标了 sensitive,state 里仍可能留下值。第四个失败是看到 Claude Code 生成了 plan 总结,就直接 apply。NAT Gateway、RDS、EKS、IAM 这类资源必须人工确认成本、停机和权限影响。
本文的示例在小型验证仓库中运行后,terraform fmt -recursive 和 terraform validate 可以通过。真实 plan 前必须替换 backend bucket,并准备 AWS 凭证。实际审查时最容易被忽略的是 NAT Gateway 成本、dev/prod state key 混用、以及 plan 中的 replace。把这些风险写进 Claude Code 提示词后,审查结果明显更稳定。
如果你的团队想把 Terraform 审查提示词、CI 模板和新人上手材料整理成标准流程,可以参考Claude Code 教材或导入咨询。个人项目也建议把本文的提示词和检查命令写进 CLAUDE.md,让每次 IaC 修改都从同一套安全规则开始。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。