Use Cases (更新: 2026/6/2)

Claude CodeでTerraform IaCを実装するAWS実践ガイド【module/state/CI】

Claude CodeでTerraform IaCを安全に実装する手順をAWS例で解説。module設計、state、CI、planレビューまで網羅。

Claude CodeでTerraform IaCを実装するAWS実践ガイド【module/state/CI】

TerraformでAWSインフラを作ると、最初は「VPCを書けた」で満足しがちです。けれど本番に近づくほど、stateの置き場所、moduleの粒度、stagingとproductionの分離、IAM権限、CIでのplanレビューが問題になります。ここを曖昧にしたままClaude Codeへ「Terraformを書いて」と頼むと、動くけれど危ないコードが混ざります。

この記事ではClaude Codeを、Terraformを書くAIではなく「IaCの実装を一緒に点検する相棒」として使います。IaCはInfrastructure as Code、つまりインフラ構成をコードで管理する考え方です。HCLはTerraformの設定言語、moduleは再利用できる部品、stateは実環境とコードの対応表、backendはstateの保存先です。初出の用語はこのように日本語へ置き換えながら進めます。

Masaが小さなAWS検証環境で試した範囲では、Claude Codeはmodule分割や変数の整理をかなり速くしてくれます。一方で「削除を含むplanをそのままapplyしない」「secretsをtfvarsへ置かない」「S3 backendのロック方式を古いままにしない」という人間側のレビュー指示がないと、危ない提案も出ます。

全体像とユースケース

この記事の流れは、要件をClaude Codeへ渡し、HCLを生成し、Terraform CLIとCIで検証し、破壊的変更を人間が止める、という順番です。

flowchart LR
  A["要件を日本語で渡す"] --> B["Claude CodeがHCLを下書き"]
  B --> C["terraform fmt / validate"]
  C --> D["terraform plan"]
  D --> E["Claude Codeと人間で差分レビュー"]
  E --> F["承認後にapply"]

実務で使いやすいユースケースは少なくとも次の4つです。

ユースケースClaude Codeに任せること人間が必ず見ること
新規サービスのVPC作成VPC、subnet、route tableのHCL下書きCIDR衝突、NAT Gatewayのコスト
module化modules/vpcへの分割、variablesとoutputsの整理moduleが責務を抱えすぎていないか
環境分離envs/dev.tfvarsenvs/prod.tfvarsの差分設計productionだけ広い権限になっていないか
CI policyfmt、validate、plan、policy checkのワークフローdestroyやreplaceが混ざったplan

Claude Codeの基本はClaude Code公式ドキュメントを、Terraformのmodule設計はTerraform Modules公式ドキュメントを確認してください。AWS権限の考え方はサイト内のAWS IAMガイドも合わせて読むとつながります。

Claude Codeへの依頼を安全にする

最初のプロンプトは、短くても制約を明確にします。Claude Codeには「コードだけ」ではなく、触ってよい範囲、禁止事項、検証コマンドまで渡します。

claude -p "
AWS向けTerraformを作成してください。
対象はVPC moduleだけです。既存ファイルを勝手に削除しないでください。

要件:
- Terraform 1.10以降を想定
- AWS providerはhashicorp/awsを使う
- VPC、public subnet 2つ、private subnet 2つ、Internet Gateway、NAT Gatewayを作る
- moduleはmodules/vpcに分ける
- dev/prodはtfvarsで分離する
- secretsやAWS access keyをtfvarsに書かない
- terraform fmt -recursive、terraform validate、terraform planの手順も出す
- planにdestroyまたはreplaceが出る場合はapplyしない前提でレビュー観点を書く
"

ポイントは「削除しない」「secretsを書かない」「destroyを止める」を明文化することです。Claude Codeは依頼が曖昧なほど一般論で補います。Terraformではその一般論が、広すぎるIAMや共有state keyのような事故につながります。

コピペで始めるTerraform構成

次の構成は、AWS認証とbackend用S3 bucketを用意すれば実際に検証できます。S3 backendは2026年6月時点の公式ドキュメントでuse_lockfileによるロックに対応しています。従来のDynamoDBロックはdeprecated扱いなので、新規構成では避けます。詳細はTerraform S3 backend公式ドキュメントを参照してください。

infra/
  versions.tf
  variables.tf
  main.tf
  outputs.tf
  envs/dev.tfvars
  modules/vpc/main.tf
  modules/vpc/variables.tf
  modules/vpc/outputs.tf

まずroot moduleです。

# 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
}
# outputs.tf
output "vpc_id" {
  value       = module.network.vpc_id
  description = "Created VPC ID."
}

output "private_subnet_ids" {
  value       = module.network.private_subnet_ids
  description = "Private subnet IDs for app workloads."
}

module設計とvariables

moduleは「AWSネットワークだけ」を担当させます。ALB、ECS、RDSまで同じmoduleに入れると、少し変えるだけで巨大なplanになります。Claude Codeへは「責務を広げない」と伝えます。

# 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

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

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
  }

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

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"

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

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

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

  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
  }

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

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]
}

backend、state、環境分離

stateにはリソースIDや一部の属性が入ります。権限を緩くすると、インフラの設計図を外に出すのと同じです。S3 bucketはversioningを有効にし、backendのkeyは環境ごとに分けます。devprodで同じ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の分離が読みやすいです。Claude Codeには「productionのbackend keyをdevと共有しない」「prod tfvarsには本番用CIDRを明記する」と指示してください。

fmt、validate、planの検証手順

Terraform CLIの公式コマンドはfmtvalidateplanを確認してください。ローカルではまず構文と型を検証します。

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

実際のAWS差分を見るときは、backend bucketとAWS認証を準備してからplanを実行します。

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

applyはplanをレビューした後です。

terraform apply tfplan

Claude Codeへplanをレビューさせるときは、次のように破壊的変更を明示的に止めます。

claude -p "
plan.txtをレビューしてください。
destroy、replace、force replacement、IAM権限拡大、public subnet公開、state key変更を最優先で指摘してください。
削除を含む場合は承認しないでください。
安全に進めるための確認質問を箇条書きで出してください。
"

CIとpolicyで人間の見落としを減らす

GitHub Actionsでは静的なAWS access keyを置かず、OIDCでAWS IAM roleを引き受ける構成が基本です。公式手順はGitHub Actions OIDC for 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 checkを入れるなら、plan JSONに対して「危ない差分」を落とします。たとえばpublic access blockを弱める変更はレビュー必須です。

package terraform.deny

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])
}

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管理も参考になります。

失敗しやすい落とし穴

1つ目は、stateを軽く見ることです。terraform.tfstateをローカルやGitに置くと、チーム作業で差分が壊れます。S3 backend、versioning、lockfile、厳しいIAMを最初に整えてください。

2つ目は、count.indexに頼りすぎることです。subnetの順番を変えただけでreplaceが出る場合があります。この記事の例のようにfor_eachと名前付きmapへ寄せると、差分が読みやすくなります。

3つ目は、Claude Codeに「本番へapplyして」と雑に頼むことです。Claude Codeは実行前のレビューを助けますが、費用や停止時間の責任は持ちません。NAT Gateway、RDS、EKSのように課金や停止影響が大きいリソースは、planに出た時点で人間が止まって確認します。

4つ目は、tfvarsへsecretを書くことです。DB password、API token、AWS access keyはSecrets ManagerやCI secretsへ逃がします。Terraformでsecret値を扱う場合もstateに残る可能性を前提にし、sensitive = trueだけで安全になったと誤解しないでください。

5つ目は、moduleを巨大化させることです。VPC moduleにALB、ECS、RDS、IAMまで入れると、再利用よりも修正コストが増えます。Claude Codeには「network moduleはnetworkだけ」と境界を繰り返し伝えます。

まとめと実際に試した結果

Claude CodeでTerraform IaCを実装するコツは、生成よりもレビューの型を作ることです。module設計、variables、backend/state、plan/apply、policy/CI、環境分離、権限とsecretsを最初の依頼に入れると、出てくるHCLの品質が安定します。

この記事で紹介した内容を実際に試した結果、terraform fmt -recursiveterraform validateはすぐ通りました。terraform planではNAT GatewayとEIPの課金が見落としやすいこと、backend bucket名を仮のままにすると初期化で止まること、proddevのstate keyを分け忘れるとレビューで気づきにくいことが確認できました。Claude Codeにはplanの要約だけでなく、destroyとreplaceを最優先で拾う指示を入れるのが有効でした。

チームでIaCレビュー基準を整えたい場合はClaude Code教材一覧導入相談も活用してください。個人開発でも、この記事のプロンプトとCI雛形をCLAUDE.mdに入れておくと、毎回のTerraform作業がかなり安全になります。

#Claude Code #Terraform #IaC #AWS #インフラ
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。